@aion0/forge 0.2.10 → 0.2.12

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/lib/init.ts CHANGED
@@ -7,19 +7,51 @@
7
7
  import { ensureRunnerStarted } from './task-manager';
8
8
  import { startTelegramBot, stopTelegramBot } from './telegram-bot';
9
9
  import { startWatcherLoop } from './session-watcher';
10
- import { getPassword } from './password';
11
- import { loadSettings } from './settings';
10
+ import { getAdminPassword } from './password';
11
+ import { loadSettings, saveSettings } from './settings';
12
12
  import { startTunnel } from './cloudflared';
13
+ import { isEncrypted, SECRET_FIELDS } from './crypto';
13
14
  import { spawn } from 'node:child_process';
14
15
  import { join } from 'node:path';
15
16
 
16
17
  const initKey = Symbol.for('mw-initialized');
17
18
  const gInit = globalThis as any;
18
19
 
20
+ /** Migrate plaintext secrets to encrypted on first run */
21
+ function migrateSecrets() {
22
+ try {
23
+ const { existsSync, readFileSync } = require('node:fs');
24
+ const { homedir } = require('node:os');
25
+ const YAML = require('yaml');
26
+ const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
27
+ const file = join(dataDir, 'settings.yaml');
28
+ if (!existsSync(file)) return;
29
+ const raw = YAML.parse(readFileSync(file, 'utf-8')) || {};
30
+ let needsSave = false;
31
+ for (const field of SECRET_FIELDS) {
32
+ if (raw[field] && typeof raw[field] === 'string' && !isEncrypted(raw[field])) {
33
+ needsSave = true;
34
+ break;
35
+ }
36
+ }
37
+ if (needsSave) {
38
+ // loadSettings returns decrypted, saveSettings encrypts
39
+ const settings = loadSettings();
40
+ saveSettings(settings);
41
+ console.log('[init] Migrated plaintext secrets to encrypted storage');
42
+ }
43
+ } catch (e) {
44
+ console.error('[init] Secret migration error:', e);
45
+ }
46
+ }
47
+
19
48
  export function ensureInitialized() {
20
49
  if (gInit[initKey]) return;
21
50
  gInit[initKey] = true;
22
51
 
52
+ // Migrate plaintext secrets on startup
53
+ migrateSecrets();
54
+
23
55
  // Task runner is safe in every worker (DB-level coordination)
24
56
  ensureRunnerStarted();
25
57
 
@@ -28,17 +60,23 @@ export function ensureInitialized() {
28
60
 
29
61
  // If services are managed externally (forge-server), skip
30
62
  if (process.env.FORGE_EXTERNAL_SERVICES === '1') {
31
- // Password display only once
32
- const password = getPassword();
33
- console.log(`[init] Login password: ${password} (valid today)`);
34
- console.log('[init] Forgot? Run: forge password');
63
+ // Password display
64
+ const admin = getAdminPassword();
65
+ if (admin) {
66
+ console.log(`[init] Admin password: configured`);
67
+ } else {
68
+ console.log('[init] No admin password set — configure in Settings');
69
+ };
35
70
  return;
36
71
  }
37
72
 
38
73
  // Standalone mode (pnpm dev without forge-server) — start everything here
39
- const password = getPassword();
40
- console.log(`[init] Login password: ${password} (valid today)`);
41
- console.log('[init] Forgot? Run: forge password');
74
+ const admin2 = getAdminPassword();
75
+ if (admin2) {
76
+ console.log(`[init] Admin password: configured`);
77
+ } else {
78
+ console.log('[init] No admin password set — configure in Settings');
79
+ }
42
80
 
43
81
  startTelegramBot(); // registers task event listener only
44
82
  startTerminalProcess();
package/lib/password.ts CHANGED
@@ -1,77 +1,97 @@
1
1
  /**
2
- * Auto-generated login password.
3
- * Rotates daily. Saved to ~/.forge/password.json with date.
4
- * CLI can read it via `forge password`.
2
+ * Password management.
3
+ *
4
+ * - Admin password: set in Settings, encrypted in settings.yaml
5
+ * Used for: local login, tunnel start, secret changes, Telegram commands
6
+ * - Session code: random 8-digit numeric, generated each time tunnel starts
7
+ * Used for: remote login 2FA (admin password + session code)
8
+ *
9
+ * Local login: admin password only
10
+ * Remote login (tunnel): admin password + session code
5
11
  */
6
12
 
7
13
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
14
  import { homedir } from 'node:os';
9
15
  import { join, dirname } from 'node:path';
10
- import { randomBytes } from 'node:crypto';
16
+ import { randomInt } from 'node:crypto';
11
17
 
12
- const PASSWORD_FILE = join(homedir(), '.forge', 'password.json');
18
+ const DATA_DIR = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
19
+ const SESSION_CODE_FILE = join(DATA_DIR, 'session-code.json');
13
20
 
14
- function generatePassword(): string {
15
- // 8-char alphanumeric, easy to type
16
- return randomBytes(6).toString('base64url').slice(0, 8);
21
+ /** Generate a random 8-digit numeric code */
22
+ function generateSessionCode(): string {
23
+ return String(randomInt(10000000, 99999999));
17
24
  }
18
25
 
19
- function todayStr(): string {
20
- return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
26
+ function readSessionCode(): string {
27
+ try {
28
+ if (!existsSync(SESSION_CODE_FILE)) return '';
29
+ const data = JSON.parse(readFileSync(SESSION_CODE_FILE, 'utf-8'));
30
+ return data?.code || '';
31
+ } catch {
32
+ return '';
33
+ }
21
34
  }
22
35
 
23
- interface PasswordData {
24
- password: string;
25
- date: string;
36
+ function saveSessionCode(code: string) {
37
+ const dir = dirname(SESSION_CODE_FILE);
38
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
+ writeFileSync(SESSION_CODE_FILE, JSON.stringify({ code }), { mode: 0o600 });
26
40
  }
27
41
 
28
- function readPasswordData(): PasswordData | null {
42
+ /** Get the admin password from settings */
43
+ export function getAdminPassword(): string {
29
44
  try {
30
- if (!existsSync(PASSWORD_FILE)) return null;
31
- return JSON.parse(readFileSync(PASSWORD_FILE, 'utf-8'));
45
+ const { loadSettings } = require('./settings');
46
+ const settings = loadSettings();
47
+ return settings.telegramTunnelPassword || '';
32
48
  } catch {
33
- return null;
49
+ return '';
34
50
  }
35
51
  }
36
52
 
37
- function savePasswordData(data: PasswordData) {
38
- const dir = dirname(PASSWORD_FILE);
39
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
- writeFileSync(PASSWORD_FILE, JSON.stringify(data), { mode: 0o600 });
53
+ /** Get current session code (empty if none) */
54
+ export function getSessionCode(): string {
55
+ return readSessionCode();
56
+ }
57
+
58
+ /** Generate new session code. Called on tunnel start. */
59
+ export function rotateSessionCode(): string {
60
+ const code = generateSessionCode();
61
+ saveSessionCode(code);
62
+ console.log(`[password] New session code: ${code}`);
63
+ return code;
41
64
  }
42
65
 
43
66
  /**
44
- * Get the current password. Priority:
45
- * 1. MW_PASSWORD env var (user explicitly set, never rotates)
46
- * 2. Saved password file if still valid today
47
- * 3. Generate new one, save with today's date
67
+ * Verify login credentials.
68
+ * @param password - admin password
69
+ * @param sessionCode - session code (required for remote, empty for local)
70
+ * @param isRemote - true if accessing via tunnel
48
71
  */
49
- export function getPassword(): string {
50
- // If user explicitly set MW_PASSWORD, use it (no rotation)
51
- if (process.env.MW_PASSWORD && process.env.MW_PASSWORD !== 'auto') {
52
- return process.env.MW_PASSWORD;
53
- }
72
+ export function verifyLogin(password: string, sessionCode?: string, isRemote?: boolean): boolean {
73
+ if (!password) return false;
54
74
 
55
- const today = todayStr();
56
- const saved = readPasswordData();
75
+ const admin = getAdminPassword();
76
+ if (!admin) return false;
77
+ if (password !== admin) return false;
57
78
 
58
- // Valid for today
59
- if (saved && saved.date === today && saved.password) {
60
- return saved.password;
79
+ // Remote access requires session code as 2FA
80
+ if (isRemote) {
81
+ const currentCode = readSessionCode();
82
+ if (!currentCode || sessionCode !== currentCode) return false;
61
83
  }
62
84
 
63
- // Expired or missing — generate new
64
- const password = generatePassword();
65
- savePasswordData({ password, date: today });
66
- console.log(`[password] New daily password generated for ${today}`);
67
- return password;
85
+ return true;
68
86
  }
69
87
 
70
- /** Read password from file (for CLI use) */
71
- export function readPasswordFile(): string | null {
72
- const data = readPasswordData();
73
- if (!data) return null;
74
- // Only return if still valid today
75
- if (data.date !== todayStr()) return null;
76
- return data.password;
88
+ /**
89
+ * Verify admin password for privileged operations
90
+ * (tunnel start, secret changes, Telegram commands).
91
+ */
92
+ export function verifyAdmin(input: string): boolean {
93
+ if (!input) return false;
94
+ const admin = getAdminPassword();
95
+ return admin ? input === admin : false;
77
96
  }
97
+
package/lib/settings.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, dirname } from 'node:path';
4
4
  import YAML from 'yaml';
5
+ import { encryptSecret, decryptSecret, isEncrypted, SECRET_FIELDS } from './crypto';
5
6
 
6
7
  const DATA_DIR = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
7
8
  const SETTINGS_FILE = join(DATA_DIR, 'settings.yaml');
@@ -15,7 +16,7 @@ export interface Settings {
15
16
  notifyOnComplete: boolean; // Notify when task completes
16
17
  notifyOnFailure: boolean; // Notify when task fails
17
18
  tunnelAutoStart: boolean; // Auto-start Cloudflare Tunnel on startup
18
- telegramTunnelPassword: string; // Password for getting login password via Telegram
19
+ telegramTunnelPassword: string; // Admin password (encrypted) — for login, tunnel, secrets, Telegram
19
20
  taskModel: string; // Model for tasks (default: sonnet)
20
21
  pipelineModel: string; // Model for pipelines (default: sonnet)
21
22
  telegramModel: string; // Model for Telegram AI features (default: sonnet)
@@ -38,18 +39,52 @@ const defaults: Settings = {
38
39
  skipPermissions: false,
39
40
  };
40
41
 
42
+ /** Load settings with secrets decrypted (for internal use) */
41
43
  export function loadSettings(): Settings {
42
44
  if (!existsSync(SETTINGS_FILE)) return { ...defaults };
43
45
  try {
44
46
  const raw = readFileSync(SETTINGS_FILE, 'utf-8');
45
- return { ...defaults, ...YAML.parse(raw) };
47
+ const parsed = { ...defaults, ...YAML.parse(raw) };
48
+ // Decrypt secret fields
49
+ for (const field of SECRET_FIELDS) {
50
+ if (parsed[field] && isEncrypted(parsed[field])) {
51
+ parsed[field] = decryptSecret(parsed[field]);
52
+ }
53
+ }
54
+ return parsed;
46
55
  } catch {
47
56
  return { ...defaults };
48
57
  }
49
58
  }
50
59
 
60
+ /** Load settings with secrets masked (for API response to frontend) */
61
+ export function loadSettingsMasked(): Settings & { _secretStatus: Record<string, boolean> } {
62
+ const settings = loadSettings();
63
+ const status: Record<string, boolean> = {};
64
+ for (const field of SECRET_FIELDS) {
65
+ status[field] = !!settings[field];
66
+ settings[field] = settings[field] ? '••••••••' : '';
67
+ }
68
+ return { ...settings, _secretStatus: status };
69
+ }
70
+
71
+ /** Save settings, encrypting secret fields */
51
72
  export function saveSettings(settings: Settings) {
52
73
  const dir = dirname(SETTINGS_FILE);
53
74
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
- writeFileSync(SETTINGS_FILE, YAML.stringify(settings), 'utf-8');
75
+ // Encrypt secret fields before saving
76
+ const toSave = { ...settings };
77
+ for (const field of SECRET_FIELDS) {
78
+ if (toSave[field] && !isEncrypted(toSave[field])) {
79
+ toSave[field] = encryptSecret(toSave[field]);
80
+ }
81
+ }
82
+ writeFileSync(SETTINGS_FILE, YAML.stringify(toSave), 'utf-8');
83
+ }
84
+
85
+ /** Verify a secret field's current value */
86
+ export function verifySecret(field: string, value: string): boolean {
87
+ const settings = loadSettings();
88
+ const current = (settings as any)[field] || '';
89
+ return current === value;
55
90
  }
@@ -13,7 +13,7 @@ import { scanProjects } from './projects';
13
13
  import { listClaudeSessions, getSessionFilePath, readSessionEntries } from './claude-sessions';
14
14
  import { listWatchers, createWatcher, deleteWatcher, toggleWatcher } from './session-watcher';
15
15
  import { startTunnel, stopTunnel, getTunnelStatus } from './cloudflared';
16
- import { getPassword } from './password';
16
+ // Password verification is done via require() in handler functions
17
17
  import type { Task, TaskLogEntry } from '@/src/types';
18
18
 
19
19
  // Persist state across hot-reloads
@@ -260,13 +260,13 @@ async function handleMessage(msg: any) {
260
260
  await handleTunnelStatus(chatId);
261
261
  break;
262
262
  case '/tunnel_start':
263
- await handleTunnelStart(chatId);
263
+ await handleTunnelStart(chatId, args[0]);
264
264
  break;
265
265
  case '/tunnel_stop':
266
266
  await handleTunnelStop(chatId);
267
267
  break;
268
- case '/tunnel_password':
269
- await handleTunnelPassword(chatId, args[0], msg.message_id);
268
+ case '/tunnel_code':
269
+ await handleTunnelCode(chatId, args[0], msg.message_id);
270
270
  break;
271
271
  default:
272
272
  await send(chatId, `Unknown command: ${cmd}\nUse /help to see available commands.`);
@@ -308,7 +308,7 @@ async function sendHelp(chatId: number) {
308
308
  `/projects — list projects\n\n` +
309
309
  `🌐 /tunnel — status\n` +
310
310
  `/tunnel_start / /tunnel_stop\n` +
311
- `/tunnel_password <pw> — get login password\n\n` +
311
+ `/tunnel_code <admin_pw> — get session code\n\n` +
312
312
  `Reply number to select`
313
313
  );
314
314
  }
@@ -892,10 +892,21 @@ async function handleTunnelStatus(chatId: number) {
892
892
  }
893
893
  }
894
894
 
895
- async function handleTunnelStart(chatId: number) {
895
+ async function handleTunnelStart(chatId: number, password?: string) {
896
896
  const settings = loadSettings();
897
897
  if (String(chatId) !== settings.telegramChatId) { await send(chatId, '⛔ Unauthorized'); return; }
898
898
 
899
+ // Require admin password
900
+ if (!password) {
901
+ await send(chatId, '🔑 Usage: /tunnel_start <password>');
902
+ return;
903
+ }
904
+ const { verifyAdmin } = require('./password');
905
+ if (!verifyAdmin(password)) {
906
+ await send(chatId, '⛔ Wrong password');
907
+ return;
908
+ }
909
+
899
910
  // Check if tunnel is already running and still reachable
900
911
  const status = getTunnelStatus();
901
912
  if (status.status === 'running' && status.url) {
@@ -936,36 +947,36 @@ async function handleTunnelStop(chatId: number) {
936
947
  await send(chatId, '🛑 Tunnel stopped');
937
948
  }
938
949
 
939
- async function handleTunnelPassword(chatId: number, password?: string, userMsgId?: number) {
950
+ async function handleTunnelCode(chatId: number, password?: string, userMsgId?: number) {
940
951
  const settings = loadSettings();
941
952
  if (String(chatId) !== settings.telegramChatId) {
942
953
  await send(chatId, '⛔ Unauthorized');
943
954
  return;
944
955
  }
945
956
 
946
- if (!settings.telegramTunnelPassword) {
947
- await send(chatId, '⚠️ Telegram tunnel password not configured.\nSet it in Settings → Remote Access → Telegram tunnel password');
948
- return;
949
- }
950
-
951
957
  if (!password) {
952
- await send(chatId, 'Usage: /tunnel_password <your-password>');
958
+ await send(chatId, 'Usage: /tunnel_code <admin-password>');
953
959
  return;
954
960
  }
955
961
 
956
962
  // Immediately delete user's message containing password
957
963
  if (userMsgId) deleteMessageLater(chatId, userMsgId, 0);
958
964
 
959
- if (password !== settings.telegramTunnelPassword) {
965
+ const { verifyAdmin, getSessionCode } = require('./password');
966
+ if (!verifyAdmin(password)) {
960
967
  await send(chatId, '⛔ Wrong password');
961
968
  return;
962
969
  }
963
970
 
964
- const loginPassword = getPassword();
971
+ // Show the session code (for remote login 2FA)
972
+ const code = getSessionCode();
965
973
  const status = getTunnelStatus();
966
- // Send password as code block, auto-delete after 30s
967
- const labelId = await send(chatId, '🔑 Login password (auto-deletes in 30s):');
968
- const pwId = await sendHtml(chatId, `<code>${loginPassword}</code>`);
974
+ if (!code) {
975
+ await send(chatId, '⚠️ No session code. Start tunnel first to generate one.');
976
+ return;
977
+ }
978
+ const labelId = await send(chatId, '🔑 Session code for remote login (auto-deletes in 30s):');
979
+ const pwId = await sendHtml(chatId, `<code>${code}</code>`);
969
980
  if (labelId) deleteMessageLater(chatId, labelId);
970
981
  if (pwId) deleteMessageLater(chatId, pwId);
971
982
  if (status.status === 'running' && status.url) {
@@ -1182,6 +1193,7 @@ async function sendNoteToDocsClaude(chatId: number, content: string) {
1182
1193
  const { join } = require('path');
1183
1194
  const { homedir } = require('os');
1184
1195
  const SESSION_NAME = 'mw-docs-claude';
1196
+ const docRoot = docRoots[0];
1185
1197
 
1186
1198
  // Check if the docs tmux session exists
1187
1199
  let sessionExists = false;
@@ -1190,9 +1202,22 @@ async function sendNoteToDocsClaude(chatId: number, content: string) {
1190
1202
  sessionExists = true;
1191
1203
  } catch {}
1192
1204
 
1205
+ // Auto-create session if it doesn't exist
1193
1206
  if (!sessionExists) {
1194
- await send(chatId, '⚠️ Docs Claude session not running. Open the Docs tab first to start it.');
1195
- return;
1207
+ try {
1208
+ execSync(`tmux new-session -d -s ${SESSION_NAME} -x 120 -y 30`, { timeout: 5000 });
1209
+ // Wait for shell to initialize
1210
+ await new Promise(r => setTimeout(r, 500));
1211
+ // cd to doc root and start claude
1212
+ const sf = settings.skipPermissions ? ' --dangerously-skip-permissions' : '';
1213
+ spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude --resume${sf}`, 'Enter'], { timeout: 5000 });
1214
+ // Wait for Claude to start up
1215
+ await new Promise(r => setTimeout(r, 3000));
1216
+ await send(chatId, '🚀 Auto-started Docs Claude session.');
1217
+ } catch (err) {
1218
+ await send(chatId, '❌ Failed to create Docs Claude session.');
1219
+ return;
1220
+ }
1196
1221
  }
1197
1222
 
1198
1223
  // Check if Claude is the active process (not shell)
@@ -1201,9 +1226,17 @@ async function sendNoteToDocsClaude(chatId: number, content: string) {
1201
1226
  paneCmd = execSync(`tmux display-message -p -t ${SESSION_NAME} '#{pane_current_command}'`, { encoding: 'utf-8', timeout: 2000 }).trim();
1202
1227
  } catch {}
1203
1228
 
1229
+ // If Claude is not running, start it
1204
1230
  if (paneCmd === 'zsh' || paneCmd === 'bash' || paneCmd === 'fish' || !paneCmd) {
1205
- await send(chatId, '⚠️ Claude is not running in the Docs session. Open the Docs tab and start Claude first.');
1206
- return;
1231
+ try {
1232
+ const sf = settings.skipPermissions ? ' --dangerously-skip-permissions' : '';
1233
+ spawnSync('tmux', ['send-keys', '-t', SESSION_NAME, `cd "${docRoot}" && claude --resume${sf}`, 'Enter'], { timeout: 5000 });
1234
+ await new Promise(r => setTimeout(r, 3000));
1235
+ await send(chatId, '🚀 Auto-started Claude in Docs session.');
1236
+ } catch {
1237
+ await send(chatId, '❌ Failed to start Claude in Docs session.');
1238
+ return;
1239
+ }
1207
1240
  }
1208
1241
 
1209
1242
  // Write content to a temp file, then use tmux to send a prompt referencing it
@@ -1357,7 +1390,7 @@ async function setBotCommands(token: string) {
1357
1390
  { command: 'tunnel', description: 'Tunnel status' },
1358
1391
  { command: 'tunnel_start', description: 'Start tunnel' },
1359
1392
  { command: 'tunnel_stop', description: 'Stop tunnel' },
1360
- { command: 'tunnel_password', description: 'Get login password' },
1393
+ { command: 'tunnel_code', description: 'Get session code for remote login' },
1361
1394
  { command: 'help', description: 'Show help' },
1362
1395
  ],
1363
1396
  }),
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  import { loadSettings } from './settings';
8
- import { getPassword } from './password';
9
8
 
10
9
  const settings = loadSettings();
11
10
  if (!settings.telegramBotToken || !settings.telegramChatId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {