@vellumai/assistant 0.3.22 → 0.3.24

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -84
  3. package/src/__tests__/approval-primitive.test.ts +72 -0
  4. package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -4
  5. package/src/__tests__/host-shell-tool.test.ts +25 -0
  6. package/src/__tests__/ipc-snapshot.test.ts +0 -42
  7. package/src/__tests__/mcp-cli.test.ts +120 -3
  8. package/src/__tests__/skill-feature-flags-integration.test.ts +0 -4
  9. package/src/__tests__/terminal-tools.test.ts +19 -1
  10. package/src/__tests__/tool-approval-handler.test.ts +94 -5
  11. package/src/__tests__/tool-executor.test.ts +1 -1
  12. package/src/cli/mcp.ts +25 -0
  13. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +13 -8
  14. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  15. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  16. package/src/config/bundled-skills/reminder/SKILL.md +7 -6
  17. package/src/config/bundled-skills/time-based-actions/SKILL.md +7 -6
  18. package/src/config/feature-flag-registry.json +8 -0
  19. package/src/config/schema.ts +10 -10
  20. package/src/config/system-prompt.ts +0 -72
  21. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +7 -7
  22. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +14 -6
  23. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  24. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -10
  25. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  26. package/src/daemon/handlers/config.ts +0 -4
  27. package/src/daemon/handlers/navigate-settings.ts +0 -1
  28. package/src/daemon/ipc-contract-inventory.json +0 -10
  29. package/src/daemon/ipc-contract.ts +0 -4
  30. package/src/daemon/lifecycle.ts +14 -2
  31. package/src/daemon/session-process.ts +2 -2
  32. package/src/daemon/shutdown-handlers.ts +1 -1
  33. package/src/instrument.ts +15 -1
  34. package/src/mcp/client.ts +3 -3
  35. package/src/memory/conversation-crud.ts +26 -4
  36. package/src/memory/migrations/119-schema-indexes-and-columns.ts +46 -18
  37. package/src/permissions/checker.ts +4 -4
  38. package/src/runtime/routes/inbound-message-handler.ts +2 -2
  39. package/src/runtime/routes/ingress-routes.ts +7 -2
  40. package/src/tools/executor.ts +2 -2
  41. package/src/tools/host-terminal/host-shell.ts +4 -29
  42. package/src/tools/swarm/delegate.ts +3 -0
  43. package/src/tools/system/navigate-settings.ts +0 -1
  44. package/src/tools/terminal/safe-env.ts +9 -0
  45. package/src/tools/tool-approval-handler.ts +2 -33
  46. package/src/tools/tool-manifest.ts +33 -88
  47. package/src/daemon/handlers/config-parental.ts +0 -164
  48. package/src/daemon/ipc-contract/parental-control.ts +0 -109
  49. package/src/security/parental-control-store.ts +0 -184
@@ -25,7 +25,6 @@ export * from './ipc-contract/memory.js';
25
25
  export * from './ipc-contract/messages.js';
26
26
  export * from './ipc-contract/notifications.js';
27
27
  export * from './ipc-contract/pairing.js';
28
- export * from './ipc-contract/parental-control.js';
29
28
  export * from './ipc-contract/schedules.js';
30
29
  export * from './ipc-contract/sessions.js';
31
30
  export * from './ipc-contract/settings.js';
@@ -50,7 +49,6 @@ import type { _MemoryServerMessages } from './ipc-contract/memory.js';
50
49
  import type { _MessagesClientMessages, _MessagesServerMessages } from './ipc-contract/messages.js';
51
50
  import type { _NotificationsClientMessages, _NotificationsServerMessages } from './ipc-contract/notifications.js';
52
51
  import type { _PairingClientMessages, _PairingServerMessages } from './ipc-contract/pairing.js';
53
- import type { _ParentalControlClientMessages, _ParentalControlServerMessages } from './ipc-contract/parental-control.js';
54
52
  import type { _SchedulesClientMessages, _SchedulesServerMessages } from './ipc-contract/schedules.js';
55
53
  import type { _SessionsClientMessages, _SessionsServerMessages } from './ipc-contract/sessions.js';
56
54
  import type { _SettingsClientMessages, _SettingsServerMessages } from './ipc-contract/settings.js';
@@ -89,7 +87,6 @@ export type ClientMessage =
89
87
  | _WorkspaceClientMessages
90
88
  | _SchedulesClientMessages
91
89
  | _DiagnosticsClientMessages
92
- | _ParentalControlClientMessages
93
90
  | _InboxClientMessages
94
91
  | _PairingClientMessages
95
92
  | _NotificationsClientMessages
@@ -116,7 +113,6 @@ export type ServerMessage =
116
113
  | _SchedulesServerMessages
117
114
  | _SettingsServerMessages
118
115
  | _DiagnosticsServerMessages
119
- | _ParentalControlServerMessages
120
116
  | _InboxServerMessages
121
117
  | _PairingServerMessages
122
118
  | _NotificationsServerMessages
@@ -8,6 +8,7 @@ import { reconcileCallsOnStartup } from '../calls/call-recovery.js';
8
8
  import { setRelayBroadcast } from '../calls/relay-server.js';
9
9
  import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
10
10
  import { setVoiceBridgeDeps } from '../calls/voice-session-bridge.js';
11
+ import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
11
12
  import {
12
13
  getQdrantUrlEnv,
13
14
  getRuntimeHttpHost,
@@ -21,8 +22,9 @@ import { syncUpdateBulletinOnStartup } from '../config/update-bulletin.js';
21
22
  import { HeartbeatService } from '../heartbeat/heartbeat-service.js';
22
23
  import { getHookManager } from '../hooks/manager.js';
23
24
  import { installTemplates } from '../hooks/templates.js';
24
- import { initSentry } from '../instrument.js';
25
+ import { closeSentry, initSentry } from '../instrument.js';
25
26
  import { initLogfire } from '../logfire.js';
27
+ import { getMcpServerManager } from '../mcp/manager.js';
26
28
  import * as attachmentsStore from '../memory/attachments-store.js';
27
29
  import * as conversationStore from '../memory/conversation-store.js';
28
30
  import { initializeDb } from '../memory/db.js';
@@ -54,7 +56,6 @@ import { createGuardianActionCopyGenerator, createGuardianFollowUpConversationGe
54
56
  import { initPairingHandlers } from './handlers/pairing.js';
55
57
  import { installCliLaunchers } from './install-cli-launchers.js';
56
58
  import type { ServerMessage } from './ipc-protocol.js';
57
- import { getMcpServerManager } from '../mcp/manager.js';
58
59
  import { initializeProvidersAndTools, registerMessagingProviders,registerWatcherProviders } from './providers-setup.js';
59
60
  import { seedInterfaceFiles } from './seed-files.js';
60
61
  import { DaemonServer } from './server.js';
@@ -96,7 +97,11 @@ export async function runDaemon(): Promise<void> {
96
97
  let socketCreated = false;
97
98
 
98
99
  try {
100
+ // Initialize crash reporting eagerly so early startup failures are
101
+ // captured. After config loads we check the opt-out flag and call
102
+ // closeSentry() if the user has disabled it.
99
103
  initSentry();
104
+
100
105
  await initLogfire();
101
106
 
102
107
  // Migration order matters: first move legacy flat files into the data dir
@@ -173,6 +178,13 @@ export async function runDaemon(): Promise<void> {
173
178
  initLogger({ dir: config.logFile.dir, retentionDays: config.logFile.retentionDays });
174
179
  }
175
180
 
181
+ // If the user has opted out of crash reporting, stop Sentry from capturing
182
+ // future events. Early-startup crashes before this point are still captured.
183
+ const collectUsageData = isAssistantFeatureFlagEnabled('feature_flags.collect-usage-data.enabled', config);
184
+ if (!collectUsageData) {
185
+ await closeSentry();
186
+ }
187
+
176
188
  await initializeProvidersAndTools(config);
177
189
 
178
190
  // Start the IPC socket BEFORE Qdrant so that clients can connect
@@ -576,9 +576,9 @@ export async function processMessage(
576
576
 
577
577
  let stateApplied = true;
578
578
  if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
579
- stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== undefined;
579
+ stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== null;
580
580
  } else if (turnResult.disposition === 'decline') {
581
- stateApplied = finalizeFollowup(request.id, 'declined') !== undefined;
581
+ stateApplied = finalizeFollowup(request.id, 'declined') !== null;
582
582
  }
583
583
 
584
584
  if (!stateApplied) {
@@ -2,8 +2,8 @@ import * as Sentry from '@sentry/node';
2
2
 
3
3
  import type { HeartbeatService } from '../heartbeat/heartbeat-service.js';
4
4
  import type { HookManager } from '../hooks/manager.js';
5
- import { getSqlite, resetDb } from '../memory/db.js';
6
5
  import type { McpServerManager } from '../mcp/manager.js';
6
+ import { getSqlite, resetDb } from '../memory/db.js';
7
7
  import type { QdrantManager } from '../memory/qdrant-manager.js';
8
8
  import type { RuntimeHttpServer } from '../runtime/http-server.js';
9
9
  import { browserManager } from '../tools/browser/browser-manager.js';
package/src/instrument.ts CHANGED
@@ -32,7 +32,12 @@ function redactObject(obj: unknown): unknown {
32
32
  return obj;
33
33
  }
34
34
 
35
- /** Call after dotenv has loaded so SENTRY_DSN is available. */
35
+ /**
36
+ * Call after dotenv has loaded so SENTRY_DSN is available.
37
+ * Always initializes Sentry to capture early startup crashes. If the user
38
+ * later opts out via the "collect-usage-data" feature flag, call closeSentry()
39
+ * after config is loaded to stop future event capturing.
40
+ */
36
41
  export function initSentry(): void {
37
42
  Sentry.init({
38
43
  dsn: getSentryDsn(),
@@ -60,3 +65,12 @@ export function initSentry(): void {
60
65
  },
61
66
  });
62
67
  }
68
+
69
+ /**
70
+ * Stop capturing future Sentry events. Called after config loads when the
71
+ * user has opted out of crash reporting so that early-startup crashes are
72
+ * still captured but subsequent events are suppressed.
73
+ */
74
+ export async function closeSentry(): Promise<void> {
75
+ await Sentry.close();
76
+ }
package/src/mcp/client.ts CHANGED
@@ -50,7 +50,7 @@ export class McpClient {
50
50
  } catch (err) {
51
51
  // Clean up the transport on failure (e.g., kill spawned stdio process)
52
52
  try { await this.client.close(); } catch { /* ignore cleanup errors */ }
53
- this.transport = undefined;
53
+ this.transport = null;
54
54
  throw err;
55
55
  }
56
56
  this.connected = true;
@@ -85,7 +85,7 @@ export class McpClient {
85
85
  const isError = result.isError === true;
86
86
 
87
87
  // Handle structuredContent if present
88
- if (result.structuredContent !== undefined && result.structuredContent !== null) {
88
+ if (result.structuredContent !== undefined) {
89
89
  return {
90
90
  content: JSON.stringify(result.structuredContent),
91
91
  isError,
@@ -96,7 +96,7 @@ export class McpClient {
96
96
  const textParts: string[] = [];
97
97
  if (Array.isArray(result.content)) {
98
98
  for (const block of result.content) {
99
- if (typeof block === 'object' && block !== null && 'type' in block) {
99
+ if (typeof block === 'object' && block !== undefined && 'type' in block) {
100
100
  if (block.type === 'text' && 'text' in block) {
101
101
  textParts.push(String(block.text));
102
102
  } else if (block.type === 'resource' && 'resource' in block) {
@@ -156,7 +156,27 @@ export function createConversation(titleOrOpts?: string | { title?: string; thre
156
156
  source,
157
157
  memoryScopeId,
158
158
  };
159
- db.insert(conversations).values(conversation).run();
159
+
160
+ // Retry on SQLITE_BUSY and SQLITE_IOERR — transient disk I/O errors or WAL
161
+ // contention can cause the first attempt to fail even under normal load.
162
+ const MAX_RETRIES = 3;
163
+ for (let attempt = 0; ; attempt++) {
164
+ try {
165
+ db.insert(conversations).values(conversation).run();
166
+ break;
167
+ } catch (err) {
168
+ const code = (err as { code?: string }).code ?? '';
169
+ if (attempt < MAX_RETRIES && (code.startsWith('SQLITE_BUSY') || code.startsWith('SQLITE_IOERR'))) {
170
+ log.warn({ attempt, conversationId: id, code }, 'createConversation: transient SQLite error, retrying');
171
+ // Synchronous sleep — createConversation is synchronous and the
172
+ // retry window is short (50-150ms), so Bun.sleepSync is appropriate.
173
+ Bun.sleepSync(50 * (attempt + 1));
174
+ continue;
175
+ }
176
+ throw err;
177
+ }
178
+ }
179
+
160
180
  return conversation;
161
181
  }
162
182
 
@@ -213,7 +233,8 @@ export async function addMessage(conversationId: string, role: string, content:
213
233
  ? metadata.userMessageChannel
214
234
  : null;
215
235
  // Wrap insert + updatedAt bump in a transaction so they're atomic.
216
- // Retry on SQLITE_BUSY in case busy_timeout is exhausted under heavy contention.
236
+ // Retry on SQLITE_BUSY* and SQLITE_IOERR* covers WAL contention variants
237
+ // (SQLITE_BUSY_SNAPSHOT, SQLITE_BUSY_RECOVERY) and transient disk I/O errors.
217
238
  // Timestamp is recomputed each attempt so a late retry doesn't persist a stale updatedAt.
218
239
  const MAX_RETRIES = 3;
219
240
  let now!: number;
@@ -243,8 +264,9 @@ export async function addMessage(conversationId: string, role: string, content:
243
264
  });
244
265
  break;
245
266
  } catch (err) {
246
- if (attempt < MAX_RETRIES && (err as { code?: string }).code === 'SQLITE_BUSY') {
247
- log.warn({ attempt, conversationId }, 'addMessage: SQLITE_BUSY, retrying');
267
+ const errCode = (err as { code?: string }).code ?? '';
268
+ if (attempt < MAX_RETRIES && (errCode.startsWith('SQLITE_BUSY') || errCode.startsWith('SQLITE_IOERR'))) {
269
+ log.warn({ attempt, conversationId, code: errCode }, 'addMessage: transient SQLite error, retrying');
248
270
  await Bun.sleep(50 * (attempt + 1));
249
271
  continue;
250
272
  }
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from '../db-connection.js';
2
+ import { getSqliteFrom } from '../db-connection.js';
2
3
 
3
4
  /**
4
5
  * Add indexes, a column, and a unique constraint for schema improvements:
@@ -15,23 +16,50 @@ export function migrateSchemaIndexesAndColumns(database: DrizzleDb): void {
15
16
  database.run(/*sql*/ `ALTER TABLE memory_jobs ADD COLUMN started_at INTEGER`);
16
17
  } catch { /* already exists */ }
17
18
 
18
- // Deduplicate before creating the unique index — the prior schema allowed
19
- // multiple rows per (notification_decision_id, channel) via the wider
20
- // (decision_id, channel, destination, attempt) unique index. Keep the
21
- // row with the latest updated_at for each group.
22
- database.run(/*sql*/ `
23
- DELETE FROM notification_deliveries
24
- WHERE id NOT IN (
25
- SELECT id FROM (
26
- SELECT id, ROW_NUMBER() OVER (
27
- PARTITION BY notification_decision_id, channel
28
- ORDER BY updated_at DESC
29
- ) AS rn
30
- FROM notification_deliveries
31
- )
32
- WHERE rn = 1
33
- )
34
- `);
19
+ // Ensure notification_decision_id column exists on notification_deliveries.
20
+ // Migration 114 (createNotificationTables) should have created this column,
21
+ // but on databases where 114 crashed mid-run the column may be absent. Rather
22
+ // than silently skipping the dedup+index step (leaving the schema incompatible
23
+ // with runtime code that writes notificationDecisionId), we add the column
24
+ // here if it is missing, then proceed unconditionally.
25
+ const raw = getSqliteFrom(database);
26
+ const notifDdl = raw.query(
27
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'notification_deliveries'`,
28
+ ).get() as { sql: string } | null;
35
29
 
36
- database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_deliveries_decision_channel ON notification_deliveries(notification_decision_id, channel)`);
30
+ if (notifDdl && !notifDdl.sql.includes('notification_decision_id')) {
31
+ // ADD COLUMN cannot carry NOT NULL without a default in SQLite, so we add
32
+ // it as nullable TEXT. Existing rows get NULL, which is valid until the
33
+ // runtime backfills or replaces them. The unique index below is created
34
+ // with WHERE NOT NULL to tolerate the transition period.
35
+ try {
36
+ database.run(/*sql*/ `ALTER TABLE notification_deliveries ADD COLUMN notification_decision_id TEXT`);
37
+ } catch { /* column was added concurrently — safe to continue */ }
38
+ }
39
+
40
+ if (notifDdl) {
41
+ // Deduplicate before creating the unique index — the prior schema allowed
42
+ // multiple rows per (notification_decision_id, channel) via the wider
43
+ // (decision_id, channel, destination, attempt) unique index. Keep the
44
+ // row with the latest updated_at for each group.
45
+ try {
46
+ database.run(/*sql*/ `
47
+ DELETE FROM notification_deliveries
48
+ WHERE id NOT IN (
49
+ SELECT id FROM (
50
+ SELECT id, ROW_NUMBER() OVER (
51
+ PARTITION BY notification_decision_id, channel
52
+ ORDER BY updated_at DESC
53
+ ) AS rn
54
+ FROM notification_deliveries
55
+ )
56
+ WHERE rn = 1
57
+ )
58
+ `);
59
+ } catch { /* deduplication failed — unique index creation below may fail too, which is non-fatal */ }
60
+
61
+ try {
62
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_deliveries_decision_channel ON notification_deliveries(notification_decision_id, channel) WHERE notification_decision_id IS NOT NULL`);
63
+ } catch { /* index already exists or constraint violation — safe to continue */ }
64
+ }
37
65
  }
@@ -398,10 +398,10 @@ async function classifyRiskUncached(toolName: string, input: Record<string, unkn
398
398
  if (HIGH_RISK_PROGRAMS.has(prog)) return RiskLevel.High;
399
399
 
400
400
  if (prog === 'rm') {
401
- // `rm` of known safe workspace files (no flags, bare filename) is
402
- // Medium rather than High so scope-limited allow rules can approve
403
- // it without needing allowHighRisk, which would bypass path checks.
404
- if (isRmOfKnownSafeFile(seg.args)) {
401
+ // Only downgrade rm of known safe workspace files for sandboxed bash.
402
+ // host_bash has a global allow rule that would auto-approve Medium-risk
403
+ // commands, so rm on the host must always require explicit approval.
404
+ if (toolName === 'bash' && isRmOfKnownSafeFile(seg.args)) {
405
405
  maxRisk = RiskLevel.Medium;
406
406
  continue;
407
407
  }
@@ -1087,9 +1087,9 @@ export async function handleChannelInbound(
1087
1087
 
1088
1088
  let stateApplied = true;
1089
1089
  if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
1090
- stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== undefined;
1090
+ stateApplied = progressFollowupState(request.id, 'dispatching', turnResult.disposition) !== null;
1091
1091
  } else if (turnResult.disposition === 'decline') {
1092
- stateApplied = finalizeFollowup(request.id, 'declined') !== undefined;
1092
+ stateApplied = finalizeFollowup(request.id, 'declined') !== null;
1093
1093
  }
1094
1094
 
1095
1095
  if (!stateApplied) {
@@ -93,8 +93,13 @@ export async function handleRevokeMember(req: Request, memberId: string): Promis
93
93
  * POST /v1/ingress/members/:id/block
94
94
  */
95
95
  export async function handleBlockMember(req: Request, memberId: string): Promise<Response> {
96
- const body = (await req.json()) as Record<string, unknown>;
97
- const reason = body.reason as string | undefined;
96
+ let reason: string | undefined;
97
+ try {
98
+ const body = (await req.json()) as Record<string, unknown>;
99
+ reason = body.reason as string | undefined;
100
+ } catch {
101
+ // Body is optional — callers may omit it entirely
102
+ }
98
103
 
99
104
  const result = blockIngressMember(memberId, reason);
100
105
 
@@ -56,8 +56,8 @@ export class ToolExecutor {
56
56
  startedAtMs: startTime,
57
57
  });
58
58
 
59
- // Run pre-execution approval gates (abort, parental controls, guardian
60
- // policy, allowed-tool-set, task-run preflight, tool registry lookup).
59
+ // Run pre-execution approval gates (abort, guardian policy,
60
+ // allowed-tool-set, task-run preflight, tool registry lookup).
61
61
  const gateResult = await this.approvalHandler.checkPreExecutionGates(
62
62
  name, input, context, executionTarget, riskLevel, startTime,
63
63
  (event) => emitLifecycleEvent(context, event),
@@ -9,38 +9,13 @@ import type { ToolDefinition } from '../../providers/types.js';
9
9
  import { redactSecrets } from '../../security/secret-scanner.js';
10
10
  import { getLogger } from '../../util/logger.js';
11
11
  import { formatShellOutput } from '../shared/shell-output.js';
12
+ import { buildSanitizedEnv } from '../terminal/safe-env.js';
12
13
  import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
13
14
 
14
15
  const log = getLogger('host-shell-tool');
15
16
 
16
- const SAFE_ENV_VARS = [
17
- 'PATH',
18
- 'HOME',
19
- 'TERM',
20
- 'LANG',
21
- 'EDITOR',
22
- 'SHELL',
23
- 'USER',
24
- 'TMPDIR',
25
- 'LC_ALL',
26
- 'LC_CTYPE',
27
- 'XDG_RUNTIME_DIR',
28
- 'DISPLAY',
29
- 'COLORTERM',
30
- 'TERM_PROGRAM',
31
- 'SSH_AUTH_SOCK',
32
- 'SSH_AGENT_PID',
33
- 'GPG_TTY',
34
- 'GNUPGHOME',
35
- ] as const;
36
-
37
- function buildSanitizedEnv(): Record<string, string> {
38
- const env: Record<string, string> = {};
39
- for (const key of SAFE_ENV_VARS) {
40
- if (process.env[key] != null) {
41
- env[key] = process.env[key]!;
42
- }
43
- }
17
+ function buildHostShellEnv(): Record<string, string> {
18
+ const env = buildSanitizedEnv();
44
19
  // Ensure ~/.local/bin and ~/.bun/bin are in PATH so `vellum` and `bun` are
45
20
  // always reachable, even when the daemon is launched from a macOS app
46
21
  // bundle that inherits a minimal PATH.
@@ -125,7 +100,7 @@ class HostShellTool implements Tool {
125
100
 
126
101
  const child = spawn('bash', ['-c', '--', command], {
127
102
  cwd: workingDir,
128
- env: buildSanitizedEnv(),
103
+ env: buildHostShellEnv(),
129
104
  stdio: ['ignore', 'pipe', 'pipe'],
130
105
  });
131
106
 
@@ -8,6 +8,7 @@ import { executeSwarm } from '../../swarm/orchestrator.js';
8
8
  import { generatePlan } from '../../swarm/router-planner.js';
9
9
  import { getLogger } from '../../util/logger.js';
10
10
  import { truncate } from '../../util/truncate.js';
11
+ import { registerTool } from '../registry.js';
11
12
  import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
12
13
 
13
14
  const log = getLogger('swarm-delegate');
@@ -178,6 +179,8 @@ export const swarmDelegateTool: Tool = {
178
179
  },
179
180
  };
180
181
 
182
+ registerTool(swarmDelegateTool);
183
+
181
184
  /** Clear all active sessions — only for testing. */
182
185
  export function _resetSwarmActive(): void {
183
186
  activeSessions.clear();
@@ -8,7 +8,6 @@ const SETTINGS_TABS = [
8
8
  'Trust',
9
9
  'Model',
10
10
  'Scheduling',
11
- 'Parental',
12
11
  ] as const;
13
12
 
14
13
  type SettingsTab = (typeof SETTINGS_TABS)[number];
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * Shared by the sandbox bash tool and skill sandbox runner.
7
7
  */
8
+ import { getGatewayInternalBaseUrl, getIngressPublicBaseUrl } from '../../config/env.js';
9
+
8
10
  const SAFE_ENV_VARS = [
9
11
  'PATH',
10
12
  'HOME',
@@ -33,5 +35,12 @@ export function buildSanitizedEnv(): Record<string, string> {
33
35
  env[key] = process.env[key]!;
34
36
  }
35
37
  }
38
+ // Always inject an internal gateway base for local control-plane/API calls.
39
+ const internalGatewayBase = getGatewayInternalBaseUrl();
40
+ env.INTERNAL_GATEWAY_BASE_URL = internalGatewayBase;
41
+ // Inject a public gateway base when ingress is configured; otherwise fall
42
+ // back to the internal base so commands remain functional in local-only mode.
43
+ const publicGatewayBase = getIngressPublicBaseUrl()?.replace(/\/+$/, '');
44
+ env.GATEWAY_BASE_URL = publicGatewayBase || internalGatewayBase;
36
45
  return env;
37
46
  }
@@ -1,5 +1,4 @@
1
1
  import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
2
- import { isToolBlocked } from '../security/parental-control-store.js';
3
2
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
4
3
  import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
5
4
  import { getLogger } from '../util/logger.js';
@@ -40,8 +39,8 @@ export type PreExecutionGateResult =
40
39
  | { allowed: false; result: ToolExecutionResult };
41
40
 
42
41
  /**
43
- * Handles pre-execution approval gates: abort checks, parental controls,
44
- * guardian policy, allowed-tool-set gating, and task-run preflight checks.
42
+ * Handles pre-execution approval gates: abort checks, guardian policy,
43
+ * allowed-tool-set gating, and task-run preflight checks.
45
44
  * These run before the interactive permission prompt flow.
46
45
  */
47
46
  export class ToolApprovalHandler {
@@ -81,36 +80,6 @@ export class ToolApprovalHandler {
81
80
  return { allowed: false, result: { content: 'Cancelled', isError: true } };
82
81
  }
83
82
 
84
- // Reject tools blocked by parental control settings before any permission check.
85
- if (isToolBlocked(name)) {
86
- log.warn(
87
- {
88
- toolName: name,
89
- sessionId: context.sessionId,
90
- conversationId: context.conversationId,
91
- principal: context.principal,
92
- reason: 'blocked_by_parental_controls',
93
- },
94
- 'Parental control blocked tool invocation',
95
- );
96
- const durationMs = Date.now() - startTime;
97
- emitLifecycleEvent({
98
- type: 'permission_denied',
99
- toolName: name,
100
- executionTarget,
101
- input,
102
- workingDir: context.workingDir,
103
- sessionId: context.sessionId,
104
- conversationId: context.conversationId,
105
- requestId: context.requestId,
106
- riskLevel,
107
- decision: 'deny',
108
- reason: 'Blocked by parental control settings',
109
- durationMs,
110
- });
111
- return { allowed: false, result: { content: 'This tool is blocked by parental control settings.', isError: true } };
112
- }
113
-
114
83
  // Reject tool invocations targeting guardian control-plane endpoints from non-guardian actors.
115
84
  const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianActorRole);
116
85
  if (guardianCheck.denied) {
@@ -6,7 +6,6 @@
6
6
  * so adding/removing tools only requires editing this manifest.
7
7
  */
8
8
 
9
- import { RiskLevel } from '../permissions/types.js';
10
9
  import { accountManageTool } from './credentials/account-registry.js';
11
10
  import { credentialStoreTool } from './credentials/vault.js';
12
11
  import { memorySaveTool, memorySearchTool, memoryUpdateTool } from './memory/register.js';
@@ -20,22 +19,34 @@ import type { Tool } from './types.js';
20
19
  import { screenWatchTool } from './watch/screen-watch.js';
21
20
 
22
21
  // ── Eager side-effect modules ───────────────────────────────────────
23
- // Importing these modules triggers a top-level `registerTool()` call.
22
+ // These static imports trigger top-level `registerTool()` side effects.
23
+ //
24
+ // IMPORTANT: These MUST be static imports (not dynamic `await import()`).
25
+ // When the daemon is compiled with `bun --compile`, dynamic imports with
26
+ // relative string literals resolve against the virtual `/$bunfs/root/`
27
+ // filesystem root rather than the module's own directory, causing
28
+ // "Cannot find module './filesystem/read.js'" crashes in production builds.
29
+ // Static imports are resolved at bundle time and are always safe.
30
+ import './assets/materialize.js';
31
+ import './assets/search.js';
32
+ import './filesystem/edit.js';
33
+ import './filesystem/read.js';
34
+ import './filesystem/view-image.js';
35
+ import './filesystem/write.js';
36
+ import './network/web-fetch.js';
37
+ import './network/web-search.js';
38
+ import './skills/delete-managed.js';
39
+ import './skills/load.js';
40
+ import './skills/scaffold-managed.js';
41
+ import './swarm/delegate.js';
42
+ import './system/request-permission.js';
43
+ import './system/version.js';
44
+ import './terminal/shell.js';
24
45
 
25
- export async function loadEagerModules(): Promise<void> {
26
- await import('./filesystem/read.js');
27
- await import('./filesystem/write.js');
28
- await import('./filesystem/edit.js');
29
- await import('./network/web-search.js');
30
- await import('./network/web-fetch.js');
31
- await import('./skills/load.js');
32
- await import('./skills/scaffold-managed.js');
33
- await import('./skills/delete-managed.js');
34
- await import('./system/request-permission.js');
35
- await import('./assets/search.js');
36
- await import('./assets/materialize.js');
37
- await import('./filesystem/view-image.js');
38
- await import('./system/version.js');
46
+ // loadEagerModules is a no-op now that all eager registrations happen via
47
+ // static imports above. Kept for API compatibility with registry.ts callers.
48
+ export function loadEagerModules(): Promise<void> {
49
+ return Promise.resolve();
39
50
  }
40
51
 
41
52
  // Tool names registered by the eager modules above. Listed explicitly so
@@ -43,6 +54,7 @@ export async function loadEagerModules(): Promise<void> {
43
54
  // already in the registry before init ran (e.g. when a test file imports
44
55
  // an eager module at the top level).
45
56
  export const eagerModuleToolNames: string[] = [
57
+ 'bash',
46
58
  'file_read',
47
59
  'file_write',
48
60
  'file_edit',
@@ -54,6 +66,7 @@ export const eagerModuleToolNames: string[] = [
54
66
  'request_system_permission',
55
67
  'asset_search',
56
68
  'asset_materialize',
69
+ 'swarm_delegate',
57
70
  'view_image',
58
71
  'version',
59
72
  ];
@@ -78,76 +91,8 @@ export const explicitTools: Tool[] = [
78
91
 
79
92
  // ── Lazy tool descriptors ───────────────────────────────────────────
80
93
  // Tools that defer module loading until first invocation.
94
+ // bash and swarm_delegate were previously lazy but are now eagerly registered
95
+ // via side-effect imports above, preserving their full definitions (including
96
+ // the `reason` field on bash) and fixing bun --compile module-not-found crashes.
81
97
 
82
- export const lazyTools: LazyToolDescriptor[] = [
83
- {
84
- name: 'bash',
85
- description: 'Execute a shell command on the local machine',
86
- category: 'terminal',
87
- defaultRiskLevel: RiskLevel.Medium,
88
- definition: {
89
- name: 'bash',
90
- description: 'Execute a shell command on the local machine',
91
- input_schema: {
92
- type: 'object',
93
- properties: {
94
- command: {
95
- type: 'string',
96
- description: 'The shell command to execute',
97
- },
98
- timeout_seconds: {
99
- type: 'number',
100
- description: 'Optional timeout in seconds. Defaults to the configured default (120s). Cannot exceed the configured maximum.',
101
- },
102
- network_mode: {
103
- type: 'string',
104
- enum: ['off', 'proxied'],
105
- description: 'Network access mode for the command. "off" (default) blocks network access; "proxied" routes traffic through the credential proxy.',
106
- },
107
- credential_ids: {
108
- type: 'array',
109
- items: { type: 'string' },
110
- description: 'Optional list of credential IDs to inject via the proxy when network_mode is "proxied".',
111
- },
112
- },
113
- required: ['command'],
114
- },
115
- },
116
- loader: async () => {
117
- const mod = await import('./terminal/shell.js');
118
- return mod.shellTool;
119
- },
120
- },
121
- {
122
- name: 'swarm_delegate',
123
- description: 'Decompose a complex task into parallel specialist subtasks and execute them concurrently.',
124
- category: 'orchestration',
125
- defaultRiskLevel: RiskLevel.Medium,
126
- definition: {
127
- name: 'swarm_delegate',
128
- description: 'Decompose a complex task into parallel specialist subtasks and execute them concurrently. Use this for multi-part tasks that benefit from parallel research, coding, and review.',
129
- input_schema: {
130
- type: 'object',
131
- properties: {
132
- objective: {
133
- type: 'string',
134
- description: 'The complex task to decompose and execute in parallel',
135
- },
136
- context: {
137
- type: 'string',
138
- description: 'Optional additional context about the task or codebase',
139
- },
140
- max_workers: {
141
- type: 'number',
142
- description: 'Maximum concurrent workers (1-6, default from config)',
143
- },
144
- },
145
- required: ['objective'],
146
- },
147
- },
148
- loader: async () => {
149
- const mod = await import('./swarm/delegate.js');
150
- return mod.swarmDelegateTool;
151
- },
152
- },
153
- ];
98
+ export const lazyTools: LazyToolDescriptor[] = [];