indieclaw-agent 1.3.3 → 2.0.0

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 (2) hide show
  1. package/index.js +424 -8
  2. package/package.json +2 -1
package/index.js CHANGED
@@ -7,11 +7,28 @@ const path = require('path');
7
7
  const os = require('os');
8
8
  const crypto = require('crypto');
9
9
  const http = require('http');
10
+ const https = require('https');
11
+
12
+ // --- Version from package.json ---
13
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'));
14
+ const VERSION = pkg.version;
10
15
 
11
16
  // --- Configuration ---
12
17
  const PORT = parseInt(process.env.INDIECLAW_PORT || '3100', 10);
13
18
  const TOKEN_FILE = path.join(os.homedir(), '.indieclaw-token');
14
19
 
20
+ // --- TLS Configuration ---
21
+ const TLS_ENABLED = process.env.INDIECLAW_TLS === '1';
22
+ const INDIECLAW_DIR = path.join(os.homedir(), '.indieclaw');
23
+ const DEFAULT_TLS_CERT = path.join(INDIECLAW_DIR, 'cert.pem');
24
+ const DEFAULT_TLS_KEY = path.join(INDIECLAW_DIR, 'key.pem');
25
+ const TLS_CERT_PATH = process.env.INDIECLAW_TLS_CERT || DEFAULT_TLS_CERT;
26
+ const TLS_KEY_PATH = process.env.INDIECLAW_TLS_KEY || DEFAULT_TLS_KEY;
27
+
28
+ // --- Push Notification Config ---
29
+ const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
30
+ const PUSH_TOKENS_FILE = path.join(INDIECLAW_DIR, 'push-tokens.json');
31
+
15
32
  function getOrCreateToken() {
16
33
  try {
17
34
  return fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
@@ -32,27 +49,174 @@ try {
32
49
  console.log('[agent] node-pty not available — terminal feature disabled');
33
50
  }
34
51
 
52
+ // --- TLS Setup ---
53
+ function ensureTlsCerts() {
54
+ if (!fs.existsSync(INDIECLAW_DIR)) {
55
+ fs.mkdirSync(INDIECLAW_DIR, { recursive: true, mode: 0o700 });
56
+ }
57
+ if (!fs.existsSync(TLS_CERT_PATH) || !fs.existsSync(TLS_KEY_PATH)) {
58
+ console.log('[agent] Generating self-signed TLS certificate...');
59
+ try {
60
+ execSync(
61
+ `openssl req -x509 -newkey rsa:2048 -keyout "${TLS_KEY_PATH}" -out "${TLS_CERT_PATH}" -days 365 -nodes -subj "/CN=indieclaw-agent"`,
62
+ { timeout: 15000, stdio: 'pipe' }
63
+ );
64
+ fs.chmodSync(TLS_KEY_PATH, 0o600);
65
+ fs.chmodSync(TLS_CERT_PATH, 0o644);
66
+ console.log('[agent] Self-signed certificate generated.');
67
+ } catch (err) {
68
+ console.error('[agent] Failed to generate TLS certificate:', err.message);
69
+ process.exit(1);
70
+ }
71
+ }
72
+ }
73
+
35
74
  // --- WebSocket Server ---
36
- const wss = new WebSocketServer({ port: PORT });
75
+ let wss;
76
+ let server;
77
+
78
+ if (TLS_ENABLED) {
79
+ ensureTlsCerts();
80
+ const tlsOptions = {
81
+ cert: fs.readFileSync(TLS_CERT_PATH),
82
+ key: fs.readFileSync(TLS_KEY_PATH),
83
+ };
84
+ server = https.createServer(tlsOptions);
85
+ wss = new WebSocketServer({ server });
86
+ server.listen(PORT);
87
+ } else {
88
+ wss = new WebSocketServer({ port: PORT });
89
+ }
90
+
37
91
  const terminals = new Map(); // id -> pty process
38
92
  const activeChats = new Map(); // id -> http.ClientRequest
39
93
 
94
+ // --- Deep Link & QR Code ---
95
+ function getMachineIP() {
96
+ // Try Tailscale first
97
+ try {
98
+ const tsIP = execSync('tailscale ip -4', { timeout: 3000, stdio: 'pipe' }).toString().trim();
99
+ if (tsIP) return tsIP;
100
+ } catch {}
101
+
102
+ // Fallback to network interfaces
103
+ const ifaces = os.networkInterfaces();
104
+ for (const name of Object.keys(ifaces)) {
105
+ for (const iface of ifaces[name]) {
106
+ if (iface.family === 'IPv4' && !iface.internal) {
107
+ return iface.address;
108
+ }
109
+ }
110
+ }
111
+ return '127.0.0.1';
112
+ }
113
+
114
+ const machineIP = getMachineIP();
115
+ const deepLink = `indieclaw://connect?host=${machineIP}&port=${PORT}&token=${AUTH_TOKEN}&name=${os.hostname()}&tls=${TLS_ENABLED ? '1' : '0'}`;
116
+
40
117
  console.log('');
41
118
  console.log(' ╔═══════════════════════════════════════╗');
42
- console.log(' ║ IndieClaw Agent v1.0.0 ║');
119
+ console.log(` ║ IndieClaw Agent v${VERSION.padEnd(16)}║`);
43
120
  console.log(' ╠═══════════════════════════════════════╣');
44
- console.log(` ║ Port: ${PORT} ║`);
121
+ console.log(` ║ Port: ${String(PORT).padEnd(30)}║`);
45
122
  console.log(` ║ Token: ${AUTH_TOKEN.substring(0, 12)}... ║`);
123
+ console.log(` ║ TLS: ${TLS_ENABLED ? 'Enabled ' : 'Disabled'} ║`);
46
124
  console.log(' ╚═══════════════════════════════════════╝');
47
125
  console.log('');
48
126
  console.log(` Full token: ${AUTH_TOKEN}`);
49
127
  console.log(' Enter this token in the IndieClaw mobile app to connect.');
50
128
  console.log('');
129
+ console.log(` Deep link: ${deepLink}`);
130
+ console.log('');
131
+
132
+ // Try to show QR code (optional dependency)
133
+ try {
134
+ const qrcode = require('qrcode-terminal');
135
+ qrcode.generate(deepLink, { small: true }, (qr) => {
136
+ console.log(' Scan this QR code with your phone:');
137
+ console.log('');
138
+ qr.split('\n').forEach((line) => console.log(' ' + line));
139
+ console.log('');
140
+ });
141
+ } catch {
142
+ // qrcode-terminal not installed, skip QR display
143
+ }
144
+
145
+ // --- OpenClaw Detection ---
146
+ function detectOpenClaw() {
147
+ return new Promise((resolve) => {
148
+ const req = http.request(
149
+ {
150
+ hostname: '127.0.0.1',
151
+ port: 18789,
152
+ path: '/v1/models',
153
+ method: 'GET',
154
+ timeout: 2000,
155
+ },
156
+ (res) => {
157
+ let body = '';
158
+ res.on('data', (chunk) => (body += chunk));
159
+ res.on('end', () => {
160
+ try {
161
+ const json = JSON.parse(body);
162
+ const models = (json.data || []).map((m) => m.id);
163
+ resolve({ available: true, models });
164
+ } catch {
165
+ resolve({ available: false, models: [] });
166
+ }
167
+ });
168
+ }
169
+ );
170
+ req.on('timeout', () => {
171
+ req.destroy();
172
+ resolve({ available: false, models: [] });
173
+ });
174
+ req.on('error', () => {
175
+ resolve({ available: false, models: [] });
176
+ });
177
+ req.end();
178
+ });
179
+ }
180
+
181
+ // Run detection on startup
182
+ detectOpenClaw().then((oc) => {
183
+ if (oc.available) {
184
+ console.log(` [OpenClaw] Detected! Models: ${oc.models.join(', ')}`);
185
+ } else {
186
+ console.log(' [OpenClaw] Not detected on port 18789');
187
+ }
188
+ });
189
+
190
+ // --- Push Notification Helpers ---
191
+ function loadPushTokens() {
192
+ try {
193
+ return JSON.parse(fs.readFileSync(PUSH_TOKENS_FILE, 'utf-8'));
194
+ } catch {
195
+ return [];
196
+ }
197
+ }
198
+
199
+ function savePushTokens(tokens) {
200
+ if (!fs.existsSync(INDIECLAW_DIR)) {
201
+ fs.mkdirSync(INDIECLAW_DIR, { recursive: true, mode: 0o700 });
202
+ }
203
+ fs.writeFileSync(PUSH_TOKENS_FILE, JSON.stringify(tokens), 'utf-8');
204
+ }
205
+
206
+ function sendPushNotification(title, body, data = {}) {
207
+ const tokens = loadPushTokens();
208
+ if (!tokens.length) return;
209
+ const payload = JSON.stringify(tokens.map(to => ({ to, title, body, data, sound: 'default' })));
210
+ const req = https.request(EXPO_PUSH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
211
+ req.on('error', () => {});
212
+ req.write(payload);
213
+ req.end();
214
+ }
51
215
 
52
216
  wss.on('connection', (ws) => {
53
217
  let authenticated = false;
54
218
 
55
- ws.on('message', (raw) => {
219
+ ws.on('message', async (raw) => {
56
220
  let msg;
57
221
  try {
58
222
  msg = JSON.parse(raw.toString());
@@ -64,7 +228,8 @@ wss.on('connection', (ws) => {
64
228
  if (!authenticated) {
65
229
  if (msg.type === 'auth' && msg.token === AUTH_TOKEN) {
66
230
  authenticated = true;
67
- return ws.send(JSON.stringify({ type: 'auth', success: true }));
231
+ const openclaw = await detectOpenClaw();
232
+ return ws.send(JSON.stringify({ type: 'auth', success: true, openclaw }));
68
233
  }
69
234
  if (msg.type === 'ping') {
70
235
  return ws.send(JSON.stringify({ type: 'pong' }));
@@ -162,6 +327,20 @@ async function handleMessage(ws, msg) {
162
327
  return handleChatSend(ws, msg);
163
328
  case 'chat.stop':
164
329
  return handleChatStop(ws, msg);
330
+ case 'push.register':
331
+ return handlePushRegister(ws, msg);
332
+ case 'push.unregister':
333
+ return handlePushUnregister(ws, msg);
334
+ case 'logs.system':
335
+ return handleLogsSystem(ws, msg);
336
+ case 'logs.search':
337
+ return handleLogsSearch(ws, msg);
338
+ case 'cron.history':
339
+ return handleCronHistory(ws, msg);
340
+ case 'agent.list':
341
+ return handleAgentList(ws, msg);
342
+ case 'agent.logs':
343
+ return handleAgentLogs(ws, msg);
165
344
  default:
166
345
  return replyError(ws, id, `Unknown message type: ${type}`);
167
346
  }
@@ -541,9 +720,19 @@ echo "[$(date)] Done (exit code: $?)"`;
541
720
  const scriptPath = path.join(os.tmpdir(), `indieclaw-update${ext}`);
542
721
  fs.writeFileSync(scriptPath, script, { mode: 0o755 });
543
722
 
544
- const child = platform === 'win32'
545
- ? spawn('cmd', ['/c', scriptPath], { detached: true, stdio: 'ignore', windowsHide: true })
546
- : spawn('bash', [scriptPath], { detached: true, stdio: 'ignore' });
723
+ let child;
724
+ if (platform === 'win32') {
725
+ child = spawn('cmd', ['/c', scriptPath], { detached: true, stdio: 'ignore', windowsHide: true });
726
+ } else if (platform === 'linux') {
727
+ // systemd-run launches the script in a separate cgroup scope,
728
+ // so it won't be killed when systemd restarts the agent service
729
+ child = spawn('systemd-run', ['--scope', '--unit=indieclaw-update', 'bash', scriptPath], {
730
+ detached: true,
731
+ stdio: 'ignore'
732
+ });
733
+ } else {
734
+ child = spawn('bash', [scriptPath], { detached: true, stdio: 'ignore' });
735
+ }
547
736
  child.unref();
548
737
 
549
738
  reply(ws, id, { updating: true });
@@ -851,12 +1040,238 @@ function handleChatStop(ws, { id, chatId }) {
851
1040
  reply(ws, id, { stopped: true });
852
1041
  }
853
1042
 
1043
+ // --- Push Registration ---
1044
+ function handlePushRegister(ws, { id, pushToken }) {
1045
+ const tokens = loadPushTokens();
1046
+ if (!tokens.includes(pushToken)) {
1047
+ tokens.push(pushToken);
1048
+ savePushTokens(tokens);
1049
+ }
1050
+ reply(ws, id, { registered: true });
1051
+ }
1052
+
1053
+ function handlePushUnregister(ws, { id, pushToken }) {
1054
+ let tokens = loadPushTokens();
1055
+ tokens = tokens.filter((t) => t !== pushToken);
1056
+ savePushTokens(tokens);
1057
+ reply(ws, id, { unregistered: true });
1058
+ }
1059
+
1060
+ // --- System Logs ---
1061
+ function handleLogsSystem(ws, { id, lines = 200 }) {
1062
+ const platform = os.platform();
1063
+
1064
+ if (platform === 'linux') {
1065
+ exec(`journalctl -n ${lines} --no-pager -o json`, { timeout: 10000, maxBuffer: 1024 * 1024 * 5 }, (err, stdout) => {
1066
+ if (err) return replyError(ws, id, err.message);
1067
+ try {
1068
+ const entries = stdout.trim().split('\n').filter(Boolean).map((line) => {
1069
+ const j = JSON.parse(line);
1070
+ return {
1071
+ timestamp: j.__REALTIME_TIMESTAMP ? new Date(parseInt(j.__REALTIME_TIMESTAMP, 10) / 1000).toISOString() : null,
1072
+ level: j.PRIORITY || 'info',
1073
+ message: j.MESSAGE || '',
1074
+ source: j.SYSLOG_IDENTIFIER || j._COMM || '',
1075
+ };
1076
+ });
1077
+ reply(ws, id, entries);
1078
+ } catch (e) {
1079
+ replyError(ws, id, 'Failed to parse journal entries: ' + e.message);
1080
+ }
1081
+ });
1082
+ } else {
1083
+ // macOS
1084
+ exec(`log show --last 1h --style json | head -${lines}`, { timeout: 15000, maxBuffer: 1024 * 1024 * 10 }, (err, stdout) => {
1085
+ if (err) {
1086
+ // Fallback: try system.log
1087
+ return exec(`tail -${lines} /var/log/system.log`, { timeout: 5000 }, (err2, stdout2) => {
1088
+ if (err2) return replyError(ws, id, err2.message);
1089
+ const entries = stdout2.trim().split('\n').filter(Boolean).map((line) => ({
1090
+ timestamp: null,
1091
+ level: 'info',
1092
+ message: line,
1093
+ source: '',
1094
+ }));
1095
+ reply(ws, id, entries);
1096
+ });
1097
+ }
1098
+ try {
1099
+ const entries = stdout.trim().split('\n').filter(Boolean).map((line) => {
1100
+ try {
1101
+ const j = JSON.parse(line);
1102
+ return {
1103
+ timestamp: j.timestamp || null,
1104
+ level: j.messageType || 'info',
1105
+ message: j.eventMessage || '',
1106
+ source: j.senderImagePath || j.processImagePath || '',
1107
+ };
1108
+ } catch {
1109
+ return { timestamp: null, level: 'info', message: line, source: '' };
1110
+ }
1111
+ });
1112
+ reply(ws, id, entries);
1113
+ } catch (e) {
1114
+ replyError(ws, id, 'Failed to parse log entries: ' + e.message);
1115
+ }
1116
+ });
1117
+ }
1118
+ }
1119
+
1120
+ // --- Log Search ---
1121
+ function handleLogsSearch(ws, { id, query, lines = 100 }) {
1122
+ const platform = os.platform();
1123
+ // Sanitize query to prevent command injection
1124
+ const safeQuery = query.replace(/["`$\\]/g, '');
1125
+
1126
+ if (platform === 'linux') {
1127
+ exec(`journalctl -n ${lines} --no-pager -g "${safeQuery}"`, { timeout: 10000, maxBuffer: 1024 * 1024 * 5 }, (err, stdout) => {
1128
+ if (err) return replyError(ws, id, err.message);
1129
+ const entries = stdout.trim().split('\n').filter(Boolean).map((line) => ({
1130
+ timestamp: null,
1131
+ level: 'info',
1132
+ message: line,
1133
+ source: '',
1134
+ }));
1135
+ reply(ws, id, entries);
1136
+ });
1137
+ } else {
1138
+ // macOS
1139
+ exec(`grep -i "${safeQuery}" /var/log/system.log | tail -${lines}`, { timeout: 10000 }, (err, stdout) => {
1140
+ if (err) return replyError(ws, id, err.message || 'No matches found');
1141
+ const entries = stdout.trim().split('\n').filter(Boolean).map((line) => ({
1142
+ timestamp: null,
1143
+ level: 'info',
1144
+ message: line,
1145
+ source: '',
1146
+ }));
1147
+ reply(ws, id, entries);
1148
+ });
1149
+ }
1150
+ }
1151
+
1152
+ // --- Cron History ---
1153
+ function handleCronHistory(ws, { id }) {
1154
+ const platform = os.platform();
1155
+
1156
+ if (platform === 'linux') {
1157
+ exec('journalctl -u cron -n 50 --no-pager -o json', { timeout: 10000, maxBuffer: 1024 * 1024 * 5 }, (err, stdout) => {
1158
+ if (err) return replyError(ws, id, err.message);
1159
+ try {
1160
+ const entries = stdout.trim().split('\n').filter(Boolean).map((line) => {
1161
+ const j = JSON.parse(line);
1162
+ return {
1163
+ timestamp: j.__REALTIME_TIMESTAMP ? new Date(parseInt(j.__REALTIME_TIMESTAMP, 10) / 1000).toISOString() : null,
1164
+ command: j.MESSAGE || '',
1165
+ exitCode: null,
1166
+ output: j.MESSAGE || '',
1167
+ };
1168
+ });
1169
+ reply(ws, id, entries);
1170
+ } catch (e) {
1171
+ replyError(ws, id, 'Failed to parse cron history: ' + e.message);
1172
+ }
1173
+ });
1174
+ } else {
1175
+ // macOS
1176
+ exec('grep CRON /var/log/system.log | tail -50', { timeout: 5000 }, (err, stdout) => {
1177
+ if (err) {
1178
+ // No cron entries or file not accessible
1179
+ return reply(ws, id, []);
1180
+ }
1181
+ const entries = stdout.trim().split('\n').filter(Boolean).map((line) => ({
1182
+ timestamp: null,
1183
+ command: line,
1184
+ exitCode: null,
1185
+ output: line,
1186
+ }));
1187
+ reply(ws, id, entries);
1188
+ });
1189
+ }
1190
+ }
1191
+
1192
+ // --- Agent List (OpenClaw Models) ---
1193
+ function handleAgentList(ws, { id }) {
1194
+ const req = http.request(
1195
+ {
1196
+ hostname: '127.0.0.1',
1197
+ port: 18789,
1198
+ path: '/v1/models',
1199
+ method: 'GET',
1200
+ timeout: 2000,
1201
+ },
1202
+ (res) => {
1203
+ let body = '';
1204
+ res.on('data', (chunk) => (body += chunk));
1205
+ res.on('end', () => {
1206
+ try {
1207
+ const json = JSON.parse(body);
1208
+ const models = (json.data || []).map((m) => ({
1209
+ id: m.id,
1210
+ name: m.id,
1211
+ status: 'running',
1212
+ port: 18789,
1213
+ }));
1214
+ reply(ws, id, models);
1215
+ } catch {
1216
+ reply(ws, id, []);
1217
+ }
1218
+ });
1219
+ }
1220
+ );
1221
+ req.on('timeout', () => {
1222
+ req.destroy();
1223
+ reply(ws, id, []);
1224
+ });
1225
+ req.on('error', () => {
1226
+ reply(ws, id, []);
1227
+ });
1228
+ req.end();
1229
+ }
1230
+
1231
+ // --- Agent Logs ---
1232
+ function handleAgentLogs(ws, { id, lines = 200 }) {
1233
+ const platform = os.platform();
1234
+
1235
+ if (platform === 'linux') {
1236
+ exec(`journalctl -u openclaw -n ${lines} --no-pager`, { timeout: 10000, maxBuffer: 1024 * 1024 * 5 }, (err, stdout) => {
1237
+ if (err) return replyError(ws, id, err.message);
1238
+ reply(ws, id, { logs: stdout || '' });
1239
+ });
1240
+ } else {
1241
+ // macOS / fallback: try common log locations
1242
+ const logLocations = [
1243
+ path.join(os.homedir(), '.openclaw', 'logs', 'openclaw.log'),
1244
+ path.join(os.homedir(), '.openclaw', 'openclaw.log'),
1245
+ '/var/log/openclaw.log',
1246
+ ];
1247
+
1248
+ let found = false;
1249
+ for (const logPath of logLocations) {
1250
+ try {
1251
+ if (fs.existsSync(logPath)) {
1252
+ exec(`tail -${lines} "${logPath}"`, { timeout: 5000 }, (err, stdout) => {
1253
+ if (err) return reply(ws, id, { logs: '' });
1254
+ reply(ws, id, { logs: stdout || '' });
1255
+ });
1256
+ found = true;
1257
+ break;
1258
+ }
1259
+ } catch {}
1260
+ }
1261
+
1262
+ if (!found) {
1263
+ reply(ws, id, { logs: '' });
1264
+ }
1265
+ }
1266
+ }
1267
+
854
1268
  // --- Graceful Shutdown ---
855
1269
  process.on('SIGINT', () => {
856
1270
  console.log('\n[agent] Shutting down...');
857
1271
  for (const [, term] of terminals) term.kill();
858
1272
  for (const [, req] of activeChats) req.destroy();
859
1273
  wss.close();
1274
+ if (server) server.close();
860
1275
  process.exit(0);
861
1276
  });
862
1277
 
@@ -864,5 +1279,6 @@ process.on('SIGTERM', () => {
864
1279
  for (const [, term] of terminals) term.kill();
865
1280
  for (const [, req] of activeChats) req.destroy();
866
1281
  wss.close();
1282
+ if (server) server.close();
867
1283
  process.exit(0);
868
1284
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "indieclaw-agent",
3
- "version": "1.3.3",
3
+ "version": "2.0.0",
4
4
  "description": "Manage your server from your phone. Agent for the IndieClaw mobile app.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "node": ">=18.0.0"
35
35
  },
36
36
  "dependencies": {
37
+ "qrcode-terminal": "^0.12.0",
37
38
  "ws": "^8.0.0"
38
39
  },
39
40
  "optionalDependencies": {