agenshield 0.6.2 → 0.7.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.
@@ -2,18 +2,39 @@
2
2
  * Wizard engine - orchestrates the setup steps
3
3
  */
4
4
  import * as crypto from 'node:crypto';
5
- import { checkPrerequisites, saveBackup, backupOriginalConfig, createUserConfig, createGroups, createAgentUser, createBrokerUser, createAllDirectories, setupSocketDirectory, verifyUsersAndGroups, verifyDirectories, generateAgentProfile, installSeatbeltProfiles, installAllWrappers, installPresetBinaries, generateBrokerPlist, installLaunchDaemon, fixSocketPermissions, createPathsConfig, deployInterceptor, copyNodeBinary, copyBrokerBinary, copyShieldClient, installGuardedShell,
5
+ import { checkPrerequisites, createUserConfig, createGroups, createAgentUser, createBrokerUser, createAllDirectories, setupSocketDirectory, verifyUsersAndGroups, verifyDirectories, generateAgentProfile, installSeatbeltProfiles, installAllWrappers, installPresetBinaries, generateBrokerPlist, installLaunchDaemon, fixSocketPermissions, createPathsConfig, deployInterceptor, copyNodeBinary, copyBrokerBinary, copyShieldClient, installGuardedShell,
6
+ // NVM (existing)
7
+ installAgentNvm, patchNvmNode,
6
8
  // Preset system
7
9
  getPreset, autoDetectPreset, } from '@agenshield/sandbox';
10
+ import {
11
+ // Homebrew
12
+ installAgentHomebrew, isAgentHomebrewInstalled,
13
+ // OpenClaw install + config + lifecycle
14
+ detectHostOpenClawVersion, installAgentOpenClaw, copyOpenClawConfig, stopHostOpenClaw, getOriginalUser, getHostOpenClawConfigPath, onboardAgentOpenClaw,
15
+ // OpenClaw LaunchDaemons
16
+ installOpenClawLaunchDaemons, startOpenClawServices, OPENCLAW_DAEMON_PLIST, OPENCLAW_GATEWAY_PLIST, } from '@agenshield/integrations';
8
17
  import { createWizardSteps, getStepsByPhase, getAllStepIds } from './types.js';
9
18
  /**
10
- * Verbose logging helper - logs messages when verbose mode is enabled
11
- * Uses stderr to bypass Ink's stdout capture
19
+ * Module-level log callback allows the setup server to receive verbose
20
+ * messages and broadcast them (e.g. via SSE) to the browser UI.
21
+ */
22
+ let _logCallback;
23
+ let _currentStepId;
24
+ export function setEngineLogCallback(cb) {
25
+ _logCallback = cb;
26
+ }
27
+ /**
28
+ * Verbose logging helper - logs messages when verbose mode is enabled.
29
+ * Writes to stderr (terminal) only in verbose mode, but ALWAYS calls
30
+ * the log callback so the setup server can forward messages to the browser.
12
31
  */
13
32
  function logVerbose(message, context) {
14
33
  if (context?.options?.verbose || process.env['AGENSHIELD_VERBOSE'] === 'true') {
15
34
  process.stderr.write(`[SETUP] ${message}\n`);
16
35
  }
36
+ // Always broadcast to the UI regardless of verbose flag
37
+ _logCallback?.(message, _currentStepId);
17
38
  }
18
39
  /**
19
40
  * Execute a step and update state
@@ -74,8 +95,9 @@ const stepExecutors = {
74
95
  const detection = await preset.detect();
75
96
  if (!detection?.found) {
76
97
  // Don't fail — let install-target step handle it
98
+ // Preserve partial detection (e.g. configPath) for scan-source
77
99
  context.preset = preset;
78
- context.presetDetection = { found: false };
100
+ context.presetDetection = detection ?? { found: false };
79
101
  context.targetInstallable = true;
80
102
  return { success: true };
81
103
  }
@@ -87,10 +109,12 @@ const stepExecutors = {
87
109
  const result = await autoDetectPreset();
88
110
  if (!result) {
89
111
  // No target found — mark as installable so install-target step can offer installation
112
+ // Try to capture partial detection (e.g. configPath with skills on disk)
90
113
  const openclawPreset = getPreset('openclaw');
91
114
  if (openclawPreset) {
92
115
  context.preset = openclawPreset;
93
- context.presetDetection = { found: false };
116
+ const partialDetection = await openclawPreset.detect();
117
+ context.presetDetection = partialDetection ?? { found: false };
94
118
  context.targetInstallable = true;
95
119
  return { success: true };
96
120
  }
@@ -123,11 +147,15 @@ const stepExecutors = {
123
147
  // Run npm install -g openclaw (no sudo — user handles npm config)
124
148
  try {
125
149
  const { execSync } = await import('node:child_process');
126
- execSync('npm install -g openclaw', {
150
+ logVerbose('Running: npm install -g openclaw', context);
151
+ const output = execSync('npm install -g openclaw', {
127
152
  encoding: 'utf-8',
128
153
  timeout: 120_000,
129
154
  stdio: 'pipe',
130
155
  });
156
+ if (output?.trim()) {
157
+ logVerbose(output.trim(), context);
158
+ }
131
159
  }
132
160
  catch (err) {
133
161
  return {
@@ -164,41 +192,437 @@ const stepExecutors = {
164
192
  confirm: async (_context) => {
165
193
  return { success: true };
166
194
  },
167
- backup: async (context) => {
168
- if (!context.presetDetection) {
169
- return { success: false, error: 'No target detected' };
195
+ // ── New setup steps ───────────────────────────────────────────────────
196
+ 'cleanup-previous': async (context) => {
197
+ logVerbose('Checking for previous installations', context);
198
+ const { execSync } = await import('node:child_process');
199
+ const fs = await import('node:fs');
200
+ // Quick check: does the default agent user exist?
201
+ let agentUserExists = false;
202
+ try {
203
+ execSync('dscl . -read /Users/ash_default_agent', { encoding: 'utf-8', stdio: 'pipe' });
204
+ agentUserExists = true;
205
+ }
206
+ catch { /* user doesn't exist */ }
207
+ if (!agentUserExists) {
208
+ logVerbose('No previous installation detected', context);
209
+ return { success: true };
210
+ }
211
+ logVerbose('Found previous installation (ash_default_agent exists), cleaning up', context);
212
+ if (context.options?.dryRun) {
213
+ logVerbose('[dry-run] Would remove previous installation', context);
214
+ return { success: true };
215
+ }
216
+ // NOTE: We cannot use forceUninstall() here because it calls stopDaemon()
217
+ // which kills the process on port 5200 — that's our own setup wizard.
218
+ // Instead we do the cleanup steps inline, skipping the daemon kill.
219
+ const sudo = (cmd) => {
220
+ try {
221
+ execSync(`sudo ${cmd}`, { encoding: 'utf-8', stdio: 'pipe' });
222
+ return true;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ };
228
+ // 1. Stop broker launchd service (but NOT the daemon on port 5200 — that's us)
229
+ const brokerPlist = '/Library/LaunchDaemons/com.agenshield.broker.plist';
230
+ if (fs.existsSync(brokerPlist)) {
231
+ logVerbose('[cleanup] Stopping broker LaunchDaemon', context);
232
+ sudo(`launchctl bootout system/com.agenshield.broker 2>/dev/null || true`);
233
+ sudo(`rm -f "${brokerPlist}"`);
234
+ }
235
+ // 2. Remove OpenClaw LaunchDaemon plists if present
236
+ for (const plist of [
237
+ '/Library/LaunchDaemons/com.agenshield.openclaw.daemon.plist',
238
+ '/Library/LaunchDaemons/com.agenshield.openclaw.gateway.plist',
239
+ ]) {
240
+ if (fs.existsSync(plist)) {
241
+ const label = plist.replace('/Library/LaunchDaemons/', '').replace('.plist', '');
242
+ logVerbose(`[cleanup] Removing ${label}`, context);
243
+ sudo(`launchctl bootout system/${label} 2>/dev/null || true`);
244
+ sudo(`rm -f "${plist}"`);
245
+ }
246
+ }
247
+ // 3. Discover and kill processes for all ash_* users
248
+ let sandboxUsers = [];
249
+ try {
250
+ const output = execSync('dscl . -list /Users', { encoding: 'utf-8' });
251
+ sandboxUsers = output.split('\n').filter(u => u.startsWith('ash_'));
252
+ }
253
+ catch { /* ignore */ }
254
+ for (const username of sandboxUsers) {
255
+ logVerbose(`[cleanup] Killing processes for ${username}`, context);
256
+ sudo(`pkill -u ${username} 2>/dev/null || true`);
257
+ }
258
+ if (sandboxUsers.length > 0) {
259
+ try {
260
+ execSync('sleep 1', { encoding: 'utf-8' });
261
+ }
262
+ catch { /* ignore */ }
263
+ for (const username of sandboxUsers) {
264
+ sudo(`pkill -9 -u ${username} 2>/dev/null || true`);
265
+ }
266
+ }
267
+ // 4. Delete sandbox users (with home dirs)
268
+ const { deleteSandboxUser } = await import('@agenshield/sandbox');
269
+ for (const username of sandboxUsers) {
270
+ logVerbose(`[cleanup] Deleting user ${username}`, context);
271
+ deleteSandboxUser(username, { removeHomeDir: true });
272
+ }
273
+ // 5. Delete ash_* groups
274
+ let ashGroups = [];
275
+ try {
276
+ const output = execSync('dscl . -list /Groups', { encoding: 'utf-8' });
277
+ ashGroups = output.split('\n').filter(g => g.startsWith('ash_'));
278
+ }
279
+ catch { /* ignore */ }
280
+ for (const groupName of ashGroups) {
281
+ logVerbose(`[cleanup] Deleting group ${groupName}`, context);
282
+ sudo(`dscl . -delete /Groups/${groupName}`);
283
+ }
284
+ // 6. Remove guarded shell from /etc/shells and disk
285
+ const guardedShellPath = '/usr/local/bin/guarded-shell';
286
+ if (fs.existsSync(guardedShellPath)) {
287
+ logVerbose('[cleanup] Removing guarded shell', context);
288
+ sudo(`sed -i '' '\\|${guardedShellPath}|d' /etc/shells`);
289
+ sudo(`rm -f "${guardedShellPath}"`);
290
+ }
291
+ // 7. Remove sudoers drop-in
292
+ if (fs.existsSync('/etc/sudoers.d/agenshield')) {
293
+ logVerbose('[cleanup] Removing /etc/sudoers.d/agenshield', context);
294
+ sudo('rm -f /etc/sudoers.d/agenshield');
295
+ }
296
+ // 8. Clean up directories
297
+ for (const dir of ['/etc/agenshield', '/var/log/agenshield', '/var/run/agenshield', '/opt/agenshield']) {
298
+ if (fs.existsSync(dir)) {
299
+ logVerbose(`[cleanup] Removing ${dir}`, context);
300
+ sudo(`rm -rf "${dir}"`);
301
+ }
302
+ }
303
+ logVerbose('Previous installation cleaned up', context);
304
+ return { success: true };
305
+ },
306
+ 'install-homebrew': async (context) => {
307
+ if (!context.userConfig) {
308
+ return { success: false, error: 'User configuration not set' };
309
+ }
310
+ const { agentUser } = context.userConfig;
311
+ const socketGroupName = context.userConfig.groups.socket.name;
312
+ if (context.options?.dryRun) {
313
+ logVerbose(`[dry-run] Would install Homebrew to ${agentUser.home}/homebrew`, context);
314
+ context.homebrewInstalled = { brewPath: `${agentUser.home}/homebrew/bin/brew`, success: true };
315
+ return { success: true };
316
+ }
317
+ // Skip if already installed (e.g., retry after partial failure)
318
+ if (await isAgentHomebrewInstalled(agentUser.home)) {
319
+ logVerbose('Homebrew already installed, skipping', context);
320
+ context.homebrewInstalled = { brewPath: `${agentUser.home}/homebrew/bin/brew`, success: true };
321
+ return { success: true };
322
+ }
323
+ logVerbose(`Installing user-specific Homebrew for ${agentUser.username}`, context);
324
+ const onLog = (msg) => logVerbose(msg, context);
325
+ const result = await installAgentHomebrew({
326
+ agentHome: agentUser.home,
327
+ agentUsername: agentUser.username,
328
+ socketGroupName,
329
+ verbose: context.options?.verbose,
330
+ onLog,
331
+ });
332
+ if (!result.success) {
333
+ return { success: false, error: result.message };
334
+ }
335
+ context.homebrewInstalled = { brewPath: result.brewPath, success: true };
336
+ return { success: true };
337
+ },
338
+ 'install-nvm': async (context) => {
339
+ if (!context.userConfig) {
340
+ return { success: false, error: 'User configuration not set' };
341
+ }
342
+ const { agentUser } = context.userConfig;
343
+ const socketGroupName = context.userConfig.groups.socket.name;
344
+ const nodeVersion = context.options?.nodeVersion || '24';
345
+ if (context.options?.dryRun) {
346
+ logVerbose(`[dry-run] Would install NVM + Node.js v${nodeVersion} for ${agentUser.username}`, context);
347
+ context.nvmInstalled = {
348
+ nvmDir: `${agentUser.home}/.nvm`,
349
+ nodeVersion: `v${nodeVersion}`,
350
+ nodeBinaryPath: `${agentUser.home}/.nvm/versions/node/v${nodeVersion}.0.0/bin/node`,
351
+ success: true,
352
+ };
353
+ return { success: true };
354
+ }
355
+ // Skip if NVM + requested Node version already installed
356
+ const nvmDir = `${agentUser.home}/.nvm`;
357
+ const nvmSh = `${nvmDir}/nvm.sh`;
358
+ const fs = await import('node:fs');
359
+ if (fs.existsSync(nvmSh)) {
360
+ try {
361
+ const { exec: execCb } = await import('node:child_process');
362
+ const { promisify } = await import('node:util');
363
+ const execAsync = promisify(execCb);
364
+ const { stdout } = await execAsync(`sudo -H -u ${agentUser.username} /bin/bash --norc --noprofile -c 'source "${nvmSh}" && nvm which ${nodeVersion}'`, { cwd: '/' });
365
+ if (stdout.trim()) {
366
+ logVerbose(`NVM + Node.js v${nodeVersion} already installed, skipping`, context);
367
+ // Still copy node binary to ensure it's up to date
368
+ const nodeResult = await copyNodeBinary(context.userConfig, stdout.trim());
369
+ if (!nodeResult.success) {
370
+ return { success: false, error: `Node binary copy failed: ${nodeResult.message}` };
371
+ }
372
+ // NOTE: Do NOT patch NVM node here — patching must happen after all
373
+ // npm installs (openclaw etc.) complete. See start-openclaw step.
374
+ context.nvmInstalled = {
375
+ nvmDir,
376
+ nodeVersion: `v${nodeVersion}`,
377
+ nodeBinaryPath: stdout.trim(),
378
+ success: true,
379
+ };
380
+ return { success: true };
381
+ }
382
+ }
383
+ catch {
384
+ // Node version not installed, proceed with full install
385
+ }
386
+ }
387
+ logVerbose(`Installing NVM + Node.js v${nodeVersion} for ${agentUser.username}`, context);
388
+ const onLog = (msg) => logVerbose(msg, context);
389
+ const result = await installAgentNvm({
390
+ agentHome: agentUser.home,
391
+ agentUsername: agentUser.username,
392
+ socketGroupName,
393
+ nodeVersion,
394
+ verbose: context.options?.verbose,
395
+ onLog,
396
+ });
397
+ if (!result.success) {
398
+ return { success: false, error: result.message };
399
+ }
400
+ // Copy NVM node binary to /opt/agenshield/bin/node-bin
401
+ logVerbose(`Copying NVM node binary to /opt/agenshield/bin/node-bin`, context);
402
+ const nodeResult = await copyNodeBinary(context.userConfig, result.nodeBinaryPath);
403
+ if (!nodeResult.success) {
404
+ return { success: false, error: `Node binary copy failed: ${nodeResult.message}` };
405
+ }
406
+ // NOTE: Do NOT patch NVM node here — patching must happen after all
407
+ // npm installs (openclaw etc.) complete. See start-openclaw step.
408
+ context.nvmInstalled = {
409
+ nvmDir: result.nvmDir,
410
+ nodeVersion: result.nodeVersion,
411
+ nodeBinaryPath: result.nodeBinaryPath,
412
+ success: true,
413
+ };
414
+ return { success: true };
415
+ },
416
+ 'configure-shell': async (context) => {
417
+ if (!context.userConfig) {
418
+ return { success: false, error: 'User configuration not set' };
419
+ }
420
+ if (context.options?.dryRun) {
421
+ logVerbose(`[dry-run] Would configure guarded shell with Homebrew + NVM paths`, context);
422
+ context.shellConfigured = { success: true };
423
+ return { success: true };
424
+ }
425
+ logVerbose('Installing guarded shell with Homebrew and NVM paths', context);
426
+ const result = await installGuardedShell(context.userConfig, { verbose: context.options?.verbose });
427
+ if (!result.success) {
428
+ return { success: false, error: result.message };
429
+ }
430
+ context.shellConfigured = { success: true };
431
+ return { success: true };
432
+ },
433
+ 'install-openclaw': async (context) => {
434
+ if (!context.userConfig) {
435
+ return { success: false, error: 'User configuration not set' };
436
+ }
437
+ const { agentUser } = context.userConfig;
438
+ const socketGroupName = context.userConfig.groups.socket.name;
439
+ // Detect host version
440
+ const hostVersion = context.presetDetection?.version || detectHostOpenClawVersion() || 'latest';
441
+ logVerbose(`Host OpenClaw version: ${hostVersion}`, context);
442
+ if (context.options?.dryRun) {
443
+ logVerbose(`[dry-run] Would install openclaw@${hostVersion} for ${agentUser.username}`, context);
444
+ context.openclawInstalled = {
445
+ version: hostVersion,
446
+ binaryPath: `${agentUser.home}/.nvm/versions/node/v24.0.0/bin/openclaw`,
447
+ success: true,
448
+ };
449
+ return { success: true };
450
+ }
451
+ logVerbose(`Installing openclaw@${hostVersion} for agent user`, context);
452
+ const onLog = (msg) => logVerbose(msg, context);
453
+ const result = await installAgentOpenClaw({
454
+ agentHome: agentUser.home,
455
+ agentUsername: agentUser.username,
456
+ socketGroupName,
457
+ targetVersion: hostVersion,
458
+ verbose: context.options?.verbose,
459
+ onLog,
460
+ });
461
+ if (!result.success) {
462
+ return { success: false, error: result.message };
463
+ }
464
+ context.openclawInstalled = {
465
+ version: result.version,
466
+ binaryPath: result.binaryPath,
467
+ success: true,
468
+ };
469
+ return { success: true };
470
+ },
471
+ 'copy-openclaw-config': async (context) => {
472
+ if (!context.userConfig) {
473
+ return { success: false, error: 'User configuration not set' };
474
+ }
475
+ const { agentUser } = context.userConfig;
476
+ const socketGroupName = context.userConfig.groups.socket.name;
477
+ // Find host config path
478
+ const sourceConfigPath = context.presetDetection?.configPath
479
+ || getHostOpenClawConfigPath();
480
+ if (context.options?.dryRun) {
481
+ logVerbose(`[dry-run] Would copy .openclaw from ${sourceConfigPath || '(not found)'} to ${agentUser.home}/.openclaw`, context);
482
+ context.openclawConfigCopied = {
483
+ configDir: `${agentUser.home}/.openclaw`,
484
+ sanitized: false,
485
+ success: true,
486
+ };
487
+ return { success: true };
488
+ }
489
+ if (!sourceConfigPath) {
490
+ logVerbose('No host .openclaw config found, creating empty config', context);
491
+ }
492
+ logVerbose(`Copying OpenClaw config to agent user`, context);
493
+ const onLog = (msg) => logVerbose(msg, context);
494
+ const result = copyOpenClawConfig({
495
+ sourceConfigPath: sourceConfigPath || '/nonexistent',
496
+ agentHome: agentUser.home,
497
+ agentUsername: agentUser.username,
498
+ socketGroup: socketGroupName,
499
+ verbose: context.options?.verbose,
500
+ onLog,
501
+ });
502
+ if (!result.success) {
503
+ return { success: false, error: result.message };
504
+ }
505
+ context.openclawConfigCopied = {
506
+ configDir: result.configDir,
507
+ sanitized: result.sanitized,
508
+ success: true,
509
+ };
510
+ return { success: true };
511
+ },
512
+ 'stop-host-openclaw': async (context) => {
513
+ const originalUser = getOriginalUser();
514
+ if (context.options?.dryRun) {
515
+ logVerbose(`[dry-run] Would stop OpenClaw daemon + gateway for user: ${originalUser}`, context);
516
+ context.hostOpenclawStopped = { daemonStopped: true, gatewayStopped: true };
517
+ return { success: true };
518
+ }
519
+ logVerbose(`Stopping host OpenClaw processes for user: ${originalUser}`, context);
520
+ const onLog = (msg) => logVerbose(msg, context);
521
+ const result = await stopHostOpenClaw({
522
+ originalUser,
523
+ verbose: context.options?.verbose,
524
+ onLog,
525
+ });
526
+ context.hostOpenclawStopped = {
527
+ daemonStopped: result.daemonStopped,
528
+ gatewayStopped: result.gatewayStopped,
529
+ };
530
+ // Non-fatal if stop fails (processes might not be running)
531
+ return { success: true };
532
+ },
533
+ 'onboard-openclaw': async (context) => {
534
+ if (!context.userConfig) {
535
+ return { success: false, error: 'User configuration not set' };
170
536
  }
171
- // Skip actual backup in dry-run mode
537
+ const { agentUser } = context.userConfig;
172
538
  if (context.options?.dryRun) {
173
- context.originalInstallation = {
174
- method: context.presetDetection.method || 'npm',
175
- packagePath: context.presetDetection.packagePath || '',
176
- binaryPath: context.presetDetection.binaryPath,
177
- configPath: context.presetDetection.configPath,
178
- version: context.presetDetection.version,
179
- };
180
- return { success: true };
181
- }
182
- // Backup original config if it exists
183
- let configBackupPath;
184
- if (context.presetDetection.configPath) {
185
- const backupResult = backupOriginalConfig(context.presetDetection.configPath);
186
- if (!backupResult.success) {
187
- return { success: false, error: backupResult.error };
188
- }
189
- configBackupPath = backupResult.backupPath;
190
- }
191
- // Store backup info for later (will be saved after user creation)
192
- context.originalInstallation = {
193
- method: context.presetDetection.method || 'npm',
194
- packagePath: context.presetDetection.packagePath || '',
195
- binaryPath: context.presetDetection.binaryPath,
196
- configPath: context.presetDetection.configPath,
197
- configBackupPath,
198
- version: context.presetDetection.version,
539
+ logVerbose('[dry-run] Would run openclaw onboard --non-interactive', context);
540
+ context.openclawOnboarded = { success: true };
541
+ return { success: true };
542
+ }
543
+ logVerbose('Running openclaw onboard for agent user', context);
544
+ const onLog = (msg) => logVerbose(msg, context);
545
+ const result = await onboardAgentOpenClaw({
546
+ agentHome: agentUser.home,
547
+ agentUsername: agentUser.username,
548
+ verbose: context.options?.verbose,
549
+ onLog,
550
+ });
551
+ context.openclawOnboarded = { success: result.success };
552
+ // Non-fatal — onboard may fail if openclaw doesn't support the flags
553
+ if (!result.success) {
554
+ logVerbose(`Onboard returned non-success (non-fatal): ${result.message}`, context);
555
+ }
556
+ return { success: true };
557
+ },
558
+ 'start-openclaw': async (context) => {
559
+ if (!context.userConfig) {
560
+ return { success: false, error: 'User configuration not set' };
561
+ }
562
+ const { agentUser } = context.userConfig;
563
+ const socketGroupName = context.userConfig.groups.socket.name;
564
+ if (context.options?.dryRun) {
565
+ logVerbose(`[dry-run] Would install OpenClaw LaunchDaemons (managed by broker)`, context);
566
+ context.openclawLaunchDaemons = {
567
+ daemonPlistPath: OPENCLAW_DAEMON_PLIST,
568
+ gatewayPlistPath: OPENCLAW_GATEWAY_PLIST,
569
+ loaded: false,
570
+ };
571
+ return { success: true };
572
+ }
573
+ // 1. Patch NVM node in-place BEFORE starting OpenClaw services.
574
+ // All npm installs (openclaw, onboard, etc.) are done by now, so it's safe
575
+ // to replace NVM's node with the interceptor wrapper. From this point on,
576
+ // every `node` invocation via NVM goes through the interceptor.
577
+ const onLog = (msg) => logVerbose(msg, context);
578
+ if (context.nvmInstalled?.success && context.nvmInstalled.nodeBinaryPath) {
579
+ logVerbose('Patching NVM node binary in-place with interceptor wrapper', context);
580
+ const patchResult = await patchNvmNode({
581
+ nodeBinaryPath: context.nvmInstalled.nodeBinaryPath,
582
+ agentUsername: agentUser.username,
583
+ socketGroupName,
584
+ interceptorPath: '/opt/agenshield/lib/interceptor/register.cjs',
585
+ socketPath: '/var/run/agenshield/agenshield.sock',
586
+ httpPort: 5201,
587
+ verbose: context.options?.verbose,
588
+ onLog,
589
+ });
590
+ if (!patchResult.success) {
591
+ return { success: false, error: `Failed to patch NVM node: ${patchResult.message}` };
592
+ }
593
+ }
594
+ // 2. Install OpenClaw LaunchDaemons (gateway + daemon) — managed by launchd/broker
595
+ logVerbose('Installing OpenClaw LaunchDaemons (broker-managed)', context);
596
+ const installResult = await installOpenClawLaunchDaemons({
597
+ agentUsername: agentUser.username,
598
+ socketGroupName,
599
+ agentHome: agentUser.home,
600
+ });
601
+ if (!installResult.success) {
602
+ return { success: false, error: installResult.message };
603
+ }
604
+ logVerbose('OpenClaw LaunchDaemons installed and loaded', context);
605
+ // 3. Start OpenClaw services via launchctl kickstart
606
+ logVerbose('Starting OpenClaw services via launchctl', context);
607
+ const startResult = await startOpenClawServices();
608
+ if (!startResult.success) {
609
+ logVerbose(`Failed to start OpenClaw services (non-fatal): ${startResult.message}`, context);
610
+ }
611
+ else {
612
+ logVerbose('OpenClaw services started', context);
613
+ }
614
+ context.openclawLaunchDaemons = {
615
+ daemonPlistPath: OPENCLAW_DAEMON_PLIST,
616
+ gatewayPlistPath: OPENCLAW_GATEWAY_PLIST,
617
+ loaded: true,
199
618
  };
200
619
  return { success: true };
201
620
  },
621
+ 'open-dashboard': async (_context) => {
622
+ // No-op: the browser is already on the setup wizard page which
623
+ // transitions to CompleteStep and polls for daemon readiness.
624
+ return { success: true };
625
+ },
202
626
  'create-groups': async (context) => {
203
627
  if (!context.userConfig) {
204
628
  return { success: false, error: 'User configuration not set' };
@@ -304,7 +728,6 @@ const stepExecutors = {
304
728
  binDir: `${agentUser.home}/bin`,
305
729
  wrappersDir: `${agentUser.home}/bin`,
306
730
  configDir: `${agentUser.home}/.openclaw`,
307
- packageDir: `${agentUser.home}/.openclaw-pkg`,
308
731
  npmDir: `${agentUser.home}/.npm`,
309
732
  socketDir: context.pathsConfig.socketDir,
310
733
  logDir: context.pathsConfig.logDir,
@@ -428,6 +851,13 @@ const stepExecutors = {
428
851
  socketGroupName: context.userConfig.groups.socket.name,
429
852
  nodeVersion: context.options?.nodeVersion,
430
853
  verbose: context.options?.verbose,
854
+ nvmResult: context.nvmInstalled ? {
855
+ success: context.nvmInstalled.success,
856
+ nvmDir: context.nvmInstalled.nvmDir,
857
+ nodeVersion: context.nvmInstalled.nodeVersion,
858
+ nodeBinaryPath: context.nvmInstalled.nodeBinaryPath,
859
+ message: '',
860
+ } : undefined,
431
861
  });
432
862
  context.wrappersInstalled = result.installedWrappers;
433
863
  if (!result.success) {
@@ -532,11 +962,14 @@ const stepExecutors = {
532
962
  }, null, 2);
533
963
  logVerbose(`Writing daemon config to ${configPath}`, context);
534
964
  // Write config file via sudo tee
965
+ logVerbose(`Running: sudo tee "${configPath}"`, context);
535
966
  execSync(`sudo tee "${configPath}" > /dev/null << 'SHIELD_EOF'
536
967
  ${shieldConfig}
537
968
  SHIELD_EOF`, { encoding: 'utf-8', stdio: 'pipe' });
538
969
  // Set ownership and permissions
970
+ logVerbose(`Running: sudo chown ${brokerUsername}:${socketGroupName} "${configPath}"`, context);
539
971
  execSync(`sudo chown ${brokerUsername}:${socketGroupName} "${configPath}"`, { encoding: 'utf-8', stdio: 'pipe' });
972
+ logVerbose(`Running: sudo chmod 640 "${configPath}"`, context);
540
973
  execSync(`sudo chmod 640 "${configPath}"`, { encoding: 'utf-8', stdio: 'pipe' });
541
974
  context.daemonConfig = {
542
975
  configPath,
@@ -551,6 +984,63 @@ SHIELD_EOF`, { encoding: 'utf-8', stdio: 'pipe' });
551
984
  };
552
985
  }
553
986
  },
987
+ 'install-sudoers': async (context) => {
988
+ if (!context.userConfig) {
989
+ return { success: false, error: 'User configuration not set' };
990
+ }
991
+ const brokerUsername = context.userConfig.brokerUser.username;
992
+ const agentUsername = context.userConfig.agentUser.username;
993
+ const agentHome = context.userConfig.agentUser.home;
994
+ if (context.options?.dryRun) {
995
+ logVerbose(`[dry-run] Would install /etc/sudoers.d/agenshield granting ${brokerUsername} sudo for ${agentUsername} operations`, context);
996
+ return { success: true };
997
+ }
998
+ try {
999
+ const { execSync } = await import('node:child_process');
1000
+ const sudoersContent = [
1001
+ '# AgenShield: allow broker to run openclaw commands as agent user',
1002
+ `${brokerUsername} ALL=(${agentUsername}) NOPASSWD: /opt/agenshield/bin/openclaw-launcher.sh *`,
1003
+ `${brokerUsername} ALL=(${agentUsername}) NOPASSWD: /usr/bin/tee ${agentHome}/.openclaw/*`,
1004
+ '',
1005
+ '# AgenShield: allow broker to manage openclaw gateway LaunchDaemon',
1006
+ `${brokerUsername} ALL=(root) NOPASSWD: /bin/launchctl kickstart system/com.agenshield.openclaw.gateway`,
1007
+ `${brokerUsername} ALL=(root) NOPASSWD: /bin/launchctl kickstart -k system/com.agenshield.openclaw.gateway`,
1008
+ `${brokerUsername} ALL=(root) NOPASSWD: /bin/launchctl kill SIGTERM system/com.agenshield.openclaw.gateway`,
1009
+ `${brokerUsername} ALL=(root) NOPASSWD: /bin/launchctl list com.agenshield.openclaw.gateway`,
1010
+ '',
1011
+ ].join('\n');
1012
+ const tmpPath = '/tmp/agenshield-sudoers';
1013
+ // 1. Write to temp file
1014
+ logVerbose('Writing sudoers rules to temp file', context);
1015
+ execSync(`sudo tee "${tmpPath}" > /dev/null`, {
1016
+ input: sudoersContent,
1017
+ encoding: 'utf-8',
1018
+ stdio: ['pipe', 'pipe', 'pipe'],
1019
+ });
1020
+ // 2. Validate with visudo
1021
+ logVerbose('Validating sudoers syntax', context);
1022
+ execSync(`sudo visudo -c -f "${tmpPath}"`, { encoding: 'utf-8', stdio: 'pipe' });
1023
+ // 3. Move to /etc/sudoers.d/
1024
+ logVerbose('Installing sudoers drop-in to /etc/sudoers.d/agenshield', context);
1025
+ execSync(`sudo mv "${tmpPath}" /etc/sudoers.d/agenshield`, { encoding: 'utf-8', stdio: 'pipe' });
1026
+ // 4. Set permissions (440 is required for sudoers files)
1027
+ execSync('sudo chmod 440 /etc/sudoers.d/agenshield', { encoding: 'utf-8', stdio: 'pipe' });
1028
+ logVerbose(`Sudoers rule installed: ${brokerUsername} → ${agentUsername} + root (launchctl)`, context);
1029
+ return { success: true };
1030
+ }
1031
+ catch (err) {
1032
+ // Clean up temp file on failure
1033
+ try {
1034
+ const { execSync } = await import('node:child_process');
1035
+ execSync('sudo rm -f /tmp/agenshield-sudoers', { stdio: 'pipe' });
1036
+ }
1037
+ catch { /* ignore */ }
1038
+ return {
1039
+ success: false,
1040
+ error: `Failed to install sudoers rule: ${err.message}`,
1041
+ };
1042
+ }
1043
+ },
554
1044
  'install-policies': async (context) => {
555
1045
  if (!context.pathsConfig) {
556
1046
  return { success: false, error: 'Configuration not set' };
@@ -590,17 +1080,22 @@ SHIELD_EOF`, { encoding: 'utf-8', stdio: 'pipe' });
590
1080
  const socketGroupName = context.userConfig.groups.socket.name;
591
1081
  // Remove stale socket from previous installs (may be root-owned, causing EACCES)
592
1082
  logVerbose('Removing stale socket file if present', context);
1083
+ logVerbose('Running: sudo rm -f /var/run/agenshield/agenshield.sock', context);
593
1084
  execSync(`sudo rm -f /var/run/agenshield/agenshield.sock`, { encoding: 'utf-8', stdio: 'pipe' });
594
1085
  // Ensure log files exist with correct ownership BEFORE loading daemon.
595
1086
  // launchd opens stdout/stderr files at process start; if they don't exist
596
1087
  // or are root-owned, the broker's output may be lost.
597
1088
  logVerbose('Ensuring log files have correct ownership', context);
1089
+ logVerbose('Running: sudo mkdir -p /var/log/agenshield', context);
598
1090
  execSync(`sudo mkdir -p /var/log/agenshield`, { encoding: 'utf-8', stdio: 'pipe' });
1091
+ logVerbose('Running: sudo touch /var/log/agenshield/broker.log /var/log/agenshield/broker.error.log', context);
599
1092
  execSync(`sudo touch /var/log/agenshield/broker.log /var/log/agenshield/broker.error.log`, { encoding: 'utf-8', stdio: 'pipe' });
1093
+ logVerbose(`Running: sudo chown ${brokerUsername}:${socketGroupName} /var/log/agenshield/broker.log /var/log/agenshield/broker.error.log`, context);
600
1094
  execSync(`sudo chown ${brokerUsername}:${socketGroupName} /var/log/agenshield/broker.log /var/log/agenshield/broker.error.log`, { encoding: 'utf-8', stdio: 'pipe' });
601
1095
  // Bootout any stale broker daemon entry from a previous install.
602
1096
  // Without this, `launchctl load` may no-op if the old entry is cached.
603
1097
  logVerbose('Removing stale launchd entry if present', context);
1098
+ logVerbose('Running: sudo launchctl bootout system/com.agenshield.broker', context);
604
1099
  try {
605
1100
  execSync(`sudo launchctl bootout system/com.agenshield.broker 2>/dev/null`, { encoding: 'utf-8', stdio: 'pipe' });
606
1101
  }
@@ -620,6 +1115,7 @@ SHIELD_EOF`, { encoding: 'utf-8', stdio: 'pipe' });
620
1115
  // start the process if launchd throttles it (e.g. ThrottleInterval from a
621
1116
  // prior crashed run). kickstart bypasses throttling.
622
1117
  logVerbose('Kickstarting broker daemon', context);
1118
+ logVerbose('Running: sudo launchctl kickstart system/com.agenshield.broker', context);
623
1119
  try {
624
1120
  execSync(`sudo launchctl kickstart system/com.agenshield.broker`, { encoding: 'utf-8', stdio: 'pipe' });
625
1121
  }
@@ -646,82 +1142,7 @@ SHIELD_EOF`, { encoding: 'utf-8', stdio: 'pipe' });
646
1142
  };
647
1143
  }
648
1144
  },
649
- migrate: async (context) => {
650
- if (!context.preset || !context.agentUser || !context.directories || !context.userConfig) {
651
- return { success: false, error: 'Missing required context for migration' };
652
- }
653
- // Get the entry command for this preset to determine binary name
654
- const entryCommand = context.preset.getEntryCommand({
655
- agentUser: context.userConfig.agentUser,
656
- directories: context.directories,
657
- entryPoint: context.options?.entryPoint,
658
- detection: context.presetDetection,
659
- });
660
- // Skip in dry-run mode
661
- if (context.options?.dryRun) {
662
- context.migration = {
663
- success: true,
664
- newPaths: {
665
- packagePath: context.directories.packageDir,
666
- binaryPath: entryCommand,
667
- configPath: `${context.directories.configDir}/config.json`,
668
- },
669
- };
670
- return { success: true };
671
- }
672
- // Use preset's migrate function
673
- const migrationContext = {
674
- agentUser: context.userConfig.agentUser,
675
- directories: context.directories,
676
- entryPoint: context.options?.entryPoint,
677
- detection: context.presetDetection,
678
- };
679
- const result = await context.preset.migrate(migrationContext);
680
- if (!result.success) {
681
- return { success: false, error: result.error };
682
- }
683
- // Re-apply correct ownership for .openclaw (broker-owned, setgid per directories.ts)
684
- // Migration may have set agent ownership; .openclaw must be broker-writable.
685
- try {
686
- const { execSync } = await import('node:child_process');
687
- const agentConfigDir = `${context.userConfig.agentUser.home}/.openclaw`;
688
- const brokerUser = context.userConfig.brokerUser.username;
689
- const socketGroup = context.userConfig.groups.socket.name;
690
- execSync(`sudo chown -R ${brokerUser}:${socketGroup} "${agentConfigDir}"`, { encoding: 'utf-8', stdio: 'pipe' });
691
- execSync(`sudo chmod 2775 "${agentConfigDir}"`, { encoding: 'utf-8', stdio: 'pipe' });
692
- }
693
- catch (err) {
694
- logVerbose(`Warning: failed to fix .openclaw ownership: ${err.message}`, context);
695
- }
696
- context.migration = {
697
- success: true,
698
- newPaths: result.newPaths,
699
- };
700
- // Now save the full backup with all the information
701
- if (context.originalInstallation && context.agentUser && result.newPaths) {
702
- const sandboxUserInfo = {
703
- username: context.agentUser.username,
704
- uid: context.agentUser.uid,
705
- gid: context.agentUser.gid,
706
- homeDir: context.agentUser.homeDir,
707
- };
708
- const migratedPaths = {
709
- packagePath: result.newPaths.packagePath,
710
- configPath: result.newPaths.configPath || `${context.directories?.configDir}/config.json`,
711
- binaryPath: result.newPaths.binaryPath,
712
- };
713
- const backupResult = saveBackup({
714
- originalInstallation: context.originalInstallation,
715
- sandboxUser: sandboxUserInfo,
716
- migratedPaths,
717
- });
718
- if (!backupResult.success) {
719
- // Log warning but don't fail - installation is already complete
720
- console.warn(`Warning: Could not save backup: ${backupResult.error}`);
721
- }
722
- }
723
- return { success: true };
724
- },
1145
+ // NOTE: migrate step removed — replaced by install-openclaw + copy-openclaw-config
725
1146
  verify: async (context) => {
726
1147
  if (!context.userConfig) {
727
1148
  return { success: false, error: 'Configuration not set' };
@@ -852,6 +1273,8 @@ async function runSteps(state, context, stepIds, onStateChange) {
852
1273
  const step = state.steps[stepIndex];
853
1274
  // Update step status to running
854
1275
  step.status = 'running';
1276
+ _currentStepId = stepId;
1277
+ logVerbose(`▶ Starting step: ${step.name} (${step.id})`, context);
855
1278
  onStateChange?.(state);
856
1279
  // Execute the step
857
1280
  const executor = stepExecutors[stepId];
@@ -859,17 +1282,22 @@ async function runSteps(state, context, stepIds, onStateChange) {
859
1282
  step.status = 'error';
860
1283
  step.error = `No executor for step: ${step.id}`;
861
1284
  state.hasError = true;
1285
+ _currentStepId = undefined;
1286
+ logVerbose(`✗ Step ${step.id}: no executor found`, context);
862
1287
  onStateChange?.(state);
863
1288
  return { success: false, error: step.error };
864
1289
  }
865
1290
  const result = await executeStep(step, context, executor);
1291
+ _currentStepId = undefined;
866
1292
  if (result.success) {
867
1293
  step.status = 'completed';
1294
+ logVerbose(`✓ Completed step: ${step.name}`, context);
868
1295
  }
869
1296
  else {
870
1297
  step.status = 'error';
871
1298
  step.error = result.error;
872
1299
  state.hasError = true;
1300
+ logVerbose(`✗ Failed step: ${step.name} — ${result.error}`, context);
873
1301
  onStateChange?.(state);
874
1302
  return { success: false, error: result.error };
875
1303
  }
@@ -904,10 +1332,14 @@ export function createWizardEngine(options) {
904
1332
  return result;
905
1333
  },
906
1334
  /**
907
- * Run setup phase (confirm through verify, excludes passcode and complete)
908
- * Called after user confirms they want to proceed
1335
+ * Run setup phase all setup steps from confirm through complete.
1336
+ * Called after user confirms they want to proceed.
1337
+ * Excludes: setup-passcode, open-dashboard, complete (handled by runFinalPhase).
909
1338
  */
910
1339
  async runSetupPhase() {
1340
+ const excludeFromSetup = [
1341
+ 'setup-passcode', 'open-dashboard', 'complete',
1342
+ ];
911
1343
  // Prompt for sudo credentials before privileged steps (skip in dry-run)
912
1344
  if (!context.options?.dryRun) {
913
1345
  const { ensureSudoAccess, startSudoKeepalive } = await import('../utils/privileges.js');
@@ -915,7 +1347,7 @@ export function createWizardEngine(options) {
915
1347
  const keepalive = startSudoKeepalive();
916
1348
  try {
917
1349
  const setupSteps = ['confirm', ...getStepsByPhase('setup')
918
- .filter((id) => id !== 'setup-passcode' && id !== 'complete')];
1350
+ .filter((id) => !excludeFromSetup.includes(id))];
919
1351
  await runSteps(state, context, setupSteps, engine.onStateChange);
920
1352
  }
921
1353
  finally {
@@ -923,17 +1355,26 @@ export function createWizardEngine(options) {
923
1355
  }
924
1356
  return;
925
1357
  }
926
- // dry-run path (unchanged)
1358
+ // dry-run path
927
1359
  const setupSteps = ['confirm', ...getStepsByPhase('setup')
928
- .filter((id) => id !== 'setup-passcode' && id !== 'complete')];
1360
+ .filter((id) => !excludeFromSetup.includes(id))];
929
1361
  await runSteps(state, context, setupSteps, engine.onStateChange);
930
1362
  },
931
1363
  /**
932
- * Run final phase (setup-passcode and complete)
1364
+ * Run migration phase no longer needed in new flow.
1365
+ * Kept for backward compatibility; does nothing.
1366
+ * @deprecated Use runSetupPhase() which now includes install-openclaw + copy-openclaw-config.
1367
+ */
1368
+ async runMigrationPhase() {
1369
+ // Migration is now part of the setup phase (install-openclaw + copy-openclaw-config)
1370
+ return;
1371
+ },
1372
+ /**
1373
+ * Run final phase (setup-passcode, open-dashboard, and complete)
933
1374
  * Called after the passcode UI has collected the passcode value
934
1375
  */
935
1376
  async runFinalPhase() {
936
- const finalSteps = ['setup-passcode', 'complete'];
1377
+ const finalSteps = ['setup-passcode', 'open-dashboard', 'complete'];
937
1378
  await runSteps(state, context, finalSteps, engine.onStateChange);
938
1379
  if (!state.hasError) {
939
1380
  state.isComplete = true;