habi-agent 0.1.3 → 0.1.5

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/src/daemon.ts ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HABI Daemon — long-running background process
4
+ * Reads config from ~/.habi/config.json
5
+ * Managed by OS service layer (Windows Service / macOS LaunchAgent / Linux systemd)
6
+ */
7
+
8
+ import { HabiLocalServer } from './server';
9
+ import { readHardwareIdentity } from './hardware';
10
+ import { registerDevice, sendHeartbeat } from './cloud';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+
15
+ const CONFIG_PATH = path.join(os.homedir(), '.habi', 'config.json');
16
+ const LOG_PATH = path.join(os.homedir(), '.habi', 'habi.log');
17
+
18
+ // ── Logging ────────────────────────────────────────────────────────────────
19
+ function log(msg: string) {
20
+ const line = `[${new Date().toISOString()}] ${msg}`;
21
+ console.log(line);
22
+ try {
23
+ fs.appendFileSync(LOG_PATH, line + '\n');
24
+ } catch { /* ignore */ }
25
+ }
26
+
27
+ // ── Load config ────────────────────────────────────────────────────────────
28
+ interface HabiConfig {
29
+ key: string;
30
+ name: string;
31
+ }
32
+
33
+ function loadConfig(): HabiConfig {
34
+ if (!fs.existsSync(CONFIG_PATH)) {
35
+ throw new Error(`HABI config not found at ${CONFIG_PATH}. Run: npx habi-agent@latest start --key <key> --name <name>`);
36
+ }
37
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
38
+ }
39
+
40
+ // ── Main ───────────────────────────────────────────────────────────────────
41
+ async function main() {
42
+ log('HABI daemon starting...');
43
+
44
+ let config: HabiConfig;
45
+ try {
46
+ config = loadConfig();
47
+ } catch (err) {
48
+ log(`Config error: ${err}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ // Inject into env so cloud.ts picks them up
53
+ process.env.HABI_KEY = config.key;
54
+ process.env.HABI_DEVICE_NAME = config.name;
55
+
56
+ const server = new HabiLocalServer();
57
+
58
+ const shutdown = () => {
59
+ log('HABI daemon shutting down...');
60
+ server.stop();
61
+ process.exit(0);
62
+ };
63
+ process.on('SIGINT', shutdown);
64
+ process.on('SIGTERM', shutdown);
65
+
66
+ try {
67
+ await server.start();
68
+ log(`Agent running on port 7432`);
69
+
70
+ const hw = readHardwareIdentity();
71
+ log(`Hardware: ${hw.fingerprint} (${hw.type})`);
72
+
73
+ log(`Registering "${config.name}" with cloud...`);
74
+ const cloudState = await registerDevice({
75
+ friendlyName: config.name,
76
+ hardwareFingerprint: hw.fingerprint,
77
+ fingerprintType: hw.type,
78
+ agentVersion: '0.1.5',
79
+ osPlatform: os.platform(),
80
+ });
81
+
82
+ if (cloudState) {
83
+ log(`✓ Registered — device ID: ${cloudState.deviceId.slice(0, 8)}...`);
84
+
85
+ let currentToken = cloudState.sessionToken;
86
+ setInterval(async () => {
87
+ const hb = await sendHeartbeat({
88
+ deviceId: cloudState.deviceId,
89
+ sessionToken: currentToken,
90
+ hardwareFingerprint: hw.fingerprint,
91
+ });
92
+ if (hb) {
93
+ currentToken = hb.sessionToken;
94
+ log('Heartbeat sent');
95
+ }
96
+ }, 30000);
97
+ } else {
98
+ log('Warning: cloud registration failed — running in local mode');
99
+ }
100
+
101
+ log('HABI daemon running. Logs: ' + LOG_PATH);
102
+ await new Promise(() => {}); // keep alive
103
+
104
+ } catch (err) {
105
+ log(`Fatal error: ${err}`);
106
+ process.exit(1);
107
+ }
108
+ }
109
+
110
+ main();
package/src/server.ts CHANGED
@@ -158,7 +158,61 @@ export class HabiLocalServer {
158
158
  }
159
159
  }
160
160
 
161
- // ── Internal helpers ────────────────────────────────────────────────────
161
+ private async handleLogActivity(req: import('http').IncomingMessage, res: import('http').ServerResponse): Promise<void> {
162
+ try {
163
+ const data = await readBody(req) as Record<string, string>;
164
+ const hw = readHardwareIdentity();
165
+ const habiKey = process.env.HABI_KEY || '';
166
+
167
+ // Console log always
168
+ const icons: Record<string, string> = {
169
+ 'claude.ai': '🟠', 'chatgpt.com': '🟢', 'cursor.sh': '🔵',
170
+ 'gemini.google.com': '🔷', 'github.com': '⚫', 'perplexity.ai': '🔵',
171
+ 'copilot.microsoft.com': '🔷', 'v0.dev': '⬛', 'character.ai': '🟣',
172
+ };
173
+ const icon = icons[data.hostname ?? ''] ?? '🤖';
174
+ const toolName = data.toolName ?? 'Unknown';
175
+ const engine = data.engine ?? 'Unknown';
176
+ console.log(`[HABI Activity] ${icon} ${toolName} — ${engine}`);
177
+
178
+ // Cloud log via the post() function from cloud module
179
+ if (habiKey) {
180
+ const logPayload: Record<string, unknown> = {
181
+ eventType: 'ACTION_ALLOWED',
182
+ operation: `${toolName} — ${engine}`,
183
+ hardwareId: hw.fingerprint,
184
+ toolName,
185
+ engine,
186
+ visitUrl: data.url ?? '',
187
+ sessionId: data.sessionId ?? '',
188
+ ts: data.ts ?? new Date().toISOString(),
189
+ };
190
+ // Use dynamic import to avoid circular dependency
191
+ Promise.resolve().then(async () => {
192
+ const https = await import('https');
193
+ const body = JSON.stringify({ ...logPayload, habiKey });
194
+ const opts: import('https').RequestOptions = {
195
+ hostname: 'dokcixjitwihtbdlkbdb.supabase.co',
196
+ port: 443,
197
+ path: '/functions/v1/log-ai-activity',
198
+ method: 'POST',
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'Content-Length': String(Buffer.byteLength(body)),
202
+ 'x-habi-key': habiKey,
203
+ },
204
+ };
205
+ const r = https.default.request(opts, (res2) => { res2.resume(); });
206
+ r.on('error', () => {});
207
+ r.write(body); r.end();
208
+ }).catch(() => {});
209
+ }
210
+
211
+ json(res, 200, { ok: true, logged: true, tool: toolName, hw: hw.fingerprint });
212
+ } catch (err) {
213
+ json(res, 400, { ok: false, error: String(err) });
214
+ }
215
+ }
162
216
  private getHardware(): HardwareIdentity {
163
217
  if (!this.hardware) {
164
218
  this.hardware = readHardwareIdentity();
@@ -221,6 +275,7 @@ export class HabiLocalServer {
221
275
  if (url === '/verify' && method === 'POST') return this.handleVerify(res);
222
276
  if (url === '/assert-context' && method === 'POST') return this.handleAssertContext(req, res);
223
277
  if (url === '/audit' && method === 'POST') return this.handleAudit(req, res);
278
+ if (url === '/log-activity' && method === 'POST') return this.handleLogActivity(req, res);
224
279
 
225
280
  json(res, 404, { error: `Unknown route: ${method} ${url}` });
226
281
  });
package/src/service.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * HABI Service Manager
3
+ * Installs/uninstalls the daemon as a persistent background service
4
+ * per operating system — no admin required on any platform.
5
+ *
6
+ * Windows : Task Scheduler (schtasks) — runs at logon, no UAC prompt
7
+ * macOS : launchd LaunchAgent — ~/Library/LaunchAgents/
8
+ * Linux : systemd user service — ~/.config/systemd/user/
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+ import { execSync, spawnSync } from 'child_process';
15
+
16
+ const SERVICE_NAME = 'com.onspot.habi';
17
+ const CONFIG_DIR = path.join(os.homedir(), '.habi');
18
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
19
+ const DAEMON_PATH = path.join(__dirname, 'daemon.js'); // resolved at runtime
20
+ const NODE_BIN = process.execPath; // exact node binary used right now
21
+ const PID_PATH = path.join(CONFIG_DIR, 'habi.pid');
22
+
23
+ // ── Config helpers ─────────────────────────────────────────────────────────
24
+ export function saveConfig(key: string, name: string): void {
25
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify({ key, name, nodeBin: NODE_BIN, daemonPath: DAEMON_PATH }, null, 2));
27
+ }
28
+
29
+ export function configExists(): boolean {
30
+ return fs.existsSync(CONFIG_PATH);
31
+ }
32
+
33
+ // ── Install ────────────────────────────────────────────────────────────────
34
+ export async function installService(): Promise<void> {
35
+ const platform = os.platform();
36
+ if (platform === 'win32') await installWindows();
37
+ else if (platform === 'darwin') await installMacos();
38
+ else await installLinux();
39
+ }
40
+
41
+ // ── Uninstall ──────────────────────────────────────────────────────────────
42
+ export async function uninstallService(): Promise<void> {
43
+ const platform = os.platform();
44
+ if (platform === 'win32') uninstallWindows();
45
+ else if (platform === 'darwin') uninstallMacos();
46
+ else uninstallLinux();
47
+ }
48
+
49
+ // ── Stop ───────────────────────────────────────────────────────────────────
50
+ export function stopService(): void {
51
+ const platform = os.platform();
52
+ try {
53
+ if (platform === 'win32') {
54
+ try { execSync(`schtasks /end /tn "${SERVICE_NAME}" > nul 2>&1`); } catch {}
55
+ } else if (platform === 'darwin') {
56
+ spawnSync('sh', ['-c', `launchctl unload ~/Library/LaunchAgents/${SERVICE_NAME}.plist 2>/dev/null || true`]);
57
+ } else {
58
+ spawnSync('sh', ['-c', 'systemctl --user stop habi 2>/dev/null || true']);
59
+ }
60
+ } catch { /* already stopped */ }
61
+ }
62
+
63
+ // ── Restart ────────────────────────────────────────────────────────────────
64
+ export function restartService(): void {
65
+ stopService();
66
+ setTimeout(() => {
67
+ const platform = os.platform();
68
+ try {
69
+ if (platform === 'win32') {
70
+ try { execSync(`schtasks /run /tn "${SERVICE_NAME}" > nul 2>&1`); } catch {}
71
+ } else if (platform === 'darwin') {
72
+ spawnSync('sh', ['-c', `launchctl load ~/Library/LaunchAgents/${SERVICE_NAME}.plist 2>/dev/null || true`]);
73
+ } else {
74
+ spawnSync('sh', ['-c', 'systemctl --user start habi 2>/dev/null || true']);
75
+ }
76
+ } catch { /* ignore */ }
77
+ }, 1500);
78
+ }
79
+
80
+ // ── Service running check ──────────────────────────────────────────────────
81
+ export function isServiceInstalled(): boolean {
82
+ const platform = os.platform();
83
+ try {
84
+ if (platform === 'win32') {
85
+ const r = spawnSync('schtasks', ['/query', '/tn', SERVICE_NAME], { encoding: 'utf8', stdio: ['ignore','pipe','pipe'] });
86
+ return r.status === 0;
87
+ } else if (platform === 'darwin') {
88
+ return fs.existsSync(`${os.homedir()}/Library/LaunchAgents/${SERVICE_NAME}.plist`);
89
+ } else {
90
+ return fs.existsSync(`${os.homedir()}/.config/systemd/user/habi.service`);
91
+ }
92
+ } catch { return false; }
93
+ }
94
+
95
+ // ── Windows: Task Scheduler ─────────────────────────────────────────────────
96
+ async function installWindows(): Promise<void> {
97
+ // Wrap in a VBScript launcher so no console window flashes
98
+ const launcherPath = path.join(CONFIG_DIR, 'launch.vbs');
99
+ const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\r\nWshShell.Run """${NODE_BIN}"" ""${DAEMON_PATH}""", 0, False\r\n`;
100
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
101
+ fs.writeFileSync(launcherPath, vbsContent);
102
+
103
+ // Delete existing task silently, then create fresh
104
+ spawnSync('schtasks', ['/delete', '/tn', SERVICE_NAME, '/f'], { stdio: ['ignore','ignore','ignore'] });
105
+
106
+ const result = spawnSync('schtasks', [
107
+ '/create',
108
+ '/tn', SERVICE_NAME,
109
+ '/tr', `wscript "${launcherPath}"`,
110
+ '/sc', 'onlogon',
111
+ '/rl', 'limited', // no UAC elevation needed
112
+ '/f', // force overwrite
113
+ '/delay', '0000:30', // 30s delay after logon (let desktop load first)
114
+ ], { encoding: 'utf8' });
115
+
116
+ if (result.status !== 0) {
117
+ throw new Error(`Task Scheduler error: ${result.stderr || result.stdout}`);
118
+ }
119
+
120
+ // Run it now without waiting for next logon
121
+ spawnSync('wscript', [launcherPath]);
122
+ }
123
+
124
+ function uninstallWindows(): void {
125
+ spawnSync('schtasks', ['/delete', '/tn', SERVICE_NAME, '/f'], { stdio: ['ignore','ignore','ignore'] });
126
+ const launcherPath = path.join(CONFIG_DIR, 'launch.vbs');
127
+ if (fs.existsSync(launcherPath)) fs.unlinkSync(launcherPath);
128
+ }
129
+
130
+ // ── macOS: launchd LaunchAgent ──────────────────────────────────────────────
131
+ async function installMacos(): Promise<void> {
132
+ const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
133
+ const plistPath = path.join(plistDir, `${SERVICE_NAME}.plist`);
134
+ const logPath = path.join(CONFIG_DIR, 'habi.log');
135
+
136
+ if (!fs.existsSync(plistDir)) fs.mkdirSync(plistDir, { recursive: true });
137
+
138
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
139
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
140
+ <plist version="1.0">
141
+ <dict>
142
+ <key>Label</key> <string>${SERVICE_NAME}</string>
143
+ <key>ProgramArguments</key>
144
+ <array>
145
+ <string>${NODE_BIN}</string>
146
+ <string>${DAEMON_PATH}</string>
147
+ </array>
148
+ <key>RunAtLoad</key> <true/>
149
+ <key>KeepAlive</key> <true/>
150
+ <key>StandardOutPath</key> <string>${logPath}</string>
151
+ <key>StandardErrorPath</key> <string>${logPath}</string>
152
+ <key>WorkingDirectory</key> <string>${CONFIG_DIR}</string>
153
+ </dict>
154
+ </plist>`;
155
+
156
+ fs.writeFileSync(plistPath, plist);
157
+
158
+ // Unload if already loaded, then load fresh
159
+ spawnSync('launchctl', ['unload', plistPath], { stdio: ['ignore','ignore','ignore'] as any });
160
+ const result = spawnSync('launchctl', ['load', '-w', plistPath], { encoding: 'utf8' });
161
+ if (result.status !== 0) {
162
+ throw new Error(`launchctl error: ${result.stderr}`);
163
+ }
164
+ }
165
+
166
+ function uninstallMacos(): void {
167
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_NAME}.plist`);
168
+ if (fs.existsSync(plistPath)) {
169
+ spawnSync('launchctl', ['unload', plistPath], { stdio: ['ignore','ignore','ignore'] as any });
170
+ fs.unlinkSync(plistPath);
171
+ }
172
+ }
173
+
174
+ // ── Linux: systemd user service ─────────────────────────────────────────────
175
+ async function installLinux(): Promise<void> {
176
+ const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
177
+ const unitPath = path.join(unitDir, 'habi.service');
178
+ const logPath = path.join(CONFIG_DIR, 'habi.log');
179
+
180
+ if (!fs.existsSync(unitDir)) fs.mkdirSync(unitDir, { recursive: true });
181
+
182
+ const unit = `[Unit]
183
+ Description=HABI Hardware Identity Agent
184
+ After=network.target
185
+
186
+ [Service]
187
+ ExecStart=${NODE_BIN} ${DAEMON_PATH}
188
+ Restart=always
189
+ RestartSec=10
190
+ WorkingDirectory=${CONFIG_DIR}
191
+ StandardOutput=append:${logPath}
192
+ StandardError=append:${logPath}
193
+ Environment=HOME=${os.homedir()}
194
+
195
+ [Install]
196
+ WantedBy=default.target
197
+ `;
198
+
199
+ fs.writeFileSync(unitPath, unit);
200
+
201
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: ['ignore','ignore','ignore'] as any });
202
+ spawnSync('systemctl', ['--user', 'enable', 'habi'], { stdio: ['ignore','ignore','ignore'] as any });
203
+ const result = spawnSync('systemctl', ['--user', 'start', 'habi'], { encoding: 'utf8' });
204
+
205
+ if (result.status !== 0) {
206
+ // systemd might not be running (WSL, older systems) — fall back to backgrounding
207
+ console.log(' (systemd not available — starting daemon directly)');
208
+ const { spawn } = require('child_process');
209
+ const child = spawn(NODE_BIN, [DAEMON_PATH], {
210
+ detached: true,
211
+ stdio: ['ignore', fs.openSync(logPath, 'a'), fs.openSync(logPath, 'a')],
212
+ });
213
+ child.unref();
214
+ }
215
+ }
216
+
217
+ function uninstallLinux(): void {
218
+ spawnSync('systemctl', ['--user', 'stop', 'habi'], { stdio: ['ignore','ignore','ignore'] as any });
219
+ spawnSync('systemctl', ['--user', 'disable', 'habi'], { stdio: ['ignore','ignore','ignore'] as any });
220
+ const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', 'habi.service');
221
+ if (fs.existsSync(unitPath)) fs.unlinkSync(unitPath);
222
+ spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: ['ignore','ignore','ignore'] as any });
223
+ }