vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -11,8 +11,24 @@ export interface ScheduleSpec {
11
11
  const SUPPORTED_RRULE_PREFIXES = ['DTSTART', 'RRULE:', 'RDATE', 'EXDATE', 'EXRULE'];
12
12
 
13
13
  function normalizeRruleExpression(expression: string): string {
14
- // Handle escaped newlines from JSON transport
15
- return expression.replace(/\\n/g, '\n').trim();
14
+ // Handle escaped newlines from JSON transport, then uppercase property name
15
+ // prefixes (before the first ';' or ':') on each line so rrulestr() receives
16
+ // the canonical uppercase form regardless of what the caller provided. We
17
+ // stop at the earliest delimiter to preserve case-sensitive parameter values
18
+ // such as timezone names in DTSTART;TZID=America/New_York:...
19
+ return expression
20
+ .replace(/\\n/g, '\n')
21
+ .trim()
22
+ .split(/\r?\n/)
23
+ .map(line => {
24
+ const colonIdx = line.indexOf(':');
25
+ const semiIdx = line.indexOf(';');
26
+ if (colonIdx === -1 && semiIdx === -1) return line;
27
+ // Uppercase only the property name (before the first ';' or ':')
28
+ const nameEnd = semiIdx !== -1 && (colonIdx === -1 || semiIdx < colonIdx) ? semiIdx : colonIdx;
29
+ return line.slice(0, nameEnd).toUpperCase() + line.slice(nameEnd);
30
+ })
31
+ .join('\n');
16
32
  }
17
33
 
18
34
  function parseRruleLines(expression: string): string[] {
@@ -129,6 +145,14 @@ export function computeNextRunAt(spec: ScheduleSpec, nowMs?: number): number {
129
145
  : rrulestr(normalized, { tzid });
130
146
  const next = parsed.after(new Date(now));
131
147
  if (!next) {
148
+ // When after() (exclusive) returns null the rule may still have a
149
+ // terminal occurrence that lands exactly on `now` — e.g. COUNT=1 or the
150
+ // final UNTIL instance. Treat that as "due right now" so claimDueSchedules
151
+ // doesn't silently skip the last run.
152
+ const exactMatch = parsed.before(new Date(now), true);
153
+ if (exactMatch && exactMatch.getTime() === now) {
154
+ return now;
155
+ }
132
156
  throw new Error(`RRULE expression has no upcoming runs after ${new Date(now).toISOString()}`);
133
157
  }
134
158
  return next.getTime();
@@ -60,7 +60,7 @@ export function normalizeScheduleSyntax(input: {
60
60
 
61
61
  // Legacy cron_expression fallback
62
62
  if (input.legacyCronExpression) {
63
- return { syntax: 'cron', expression: input.legacyCronExpression };
63
+ return { syntax: input.syntax ?? 'cron', expression: input.legacyCronExpression };
64
64
  }
65
65
 
66
66
  return null;
@@ -4,8 +4,11 @@ import { Cron } from 'croner';
4
4
  import { getDb } from '../memory/db.js';
5
5
  import { scheduleJobs, scheduleRuns } from '../memory/schema.js';
6
6
  import { computeNextRunAt as computeNextRunAtEngine, isValidScheduleExpression } from './recurrence-engine.js';
7
+ import { getLogger } from '../util/logger.js';
7
8
  import type { ScheduleSyntax } from './recurrence-types.js';
8
9
 
10
+ const logger = getLogger('schedule-store');
11
+
9
12
  export interface ScheduleJob {
10
13
  id: string;
11
14
  name: string;
@@ -216,9 +219,15 @@ export function claimDueSchedules(now: number): ScheduleJob[] {
216
219
  expression: row.cronExpression,
217
220
  timezone: row.timezone,
218
221
  });
219
- } catch {
220
- // Finite schedule with no future runs still claim the current due
221
- // run but disable the schedule so it doesn't fire again.
222
+ } catch (err) {
223
+ const msg = err instanceof Error ? err.message : String(err);
224
+ if (!msg.includes('no upcoming runs')) {
225
+ // Log but don't abort — one bad schedule shouldn't block everything
226
+ logger.warn({ err, scheduleId: row.id }, 'Failed to compute next run for schedule');
227
+ continue;
228
+ }
229
+ // Expired schedules fire their final pending due run then auto-disable,
230
+ // ensuring no due run is silently dropped.
222
231
  newNextRunAt = null;
223
232
  exhausted = true;
224
233
  }
@@ -81,6 +81,13 @@ const PATTERNS: SecretPattern[] = [
81
81
  regex: /(https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+)/g,
82
82
  },
83
83
 
84
+ // -- Telegram --
85
+ {
86
+ type: 'Telegram Bot Token',
87
+ // Format: <bot_id>:<secret> where bot_id is 8-10 digits and secret is 35 alphanumeric/dash/underscore chars
88
+ regex: /\b([0-9]{8,10}:[A-Za-z0-9_-]{35})(?=[^A-Za-z0-9_-]|$)/g,
89
+ },
90
+
84
91
  // -- Anthropic --
85
92
  {
86
93
  type: 'Anthropic API Key',
@@ -44,7 +44,5 @@ export function buildTaskRules(taskRunId: string, requiredTools: string[], _work
44
44
  allowHighRisk: true,
45
45
  priority: 75,
46
46
  createdAt: Date.now(),
47
- principalKind: 'task',
48
- principalId: taskRunId,
49
47
  }));
50
48
  }
@@ -1,7 +1,8 @@
1
1
  import { createSchedule } from '../schedule/schedule-store.js';
2
2
 
3
3
  /**
4
- * Create a recurrence schedule that runs a task on a cron or RRULE expression.
4
+ * Create a cron schedule that runs a task on a recurring cron expression.
5
+ * RRULE syntax is supported at the store layer but this helper currently defaults to cron.
5
6
  * The scheduler detects the `run_task:<taskId>` message format
6
7
  * and delegates to runTask() instead of processMessage().
7
8
  */
@@ -24,6 +24,11 @@ const definition: ToolDefinition = {
24
24
  type: 'string',
25
25
  description: 'Additional context for the conversation',
26
26
  },
27
+ caller_identity_mode: {
28
+ type: 'string',
29
+ enum: ['assistant_number', 'user_number'],
30
+ description: 'Which phone number to use as the caller ID. assistant_number uses the AI assistant\'s Twilio number; user_number uses the user\'s verified personal number.',
31
+ },
27
32
  },
28
33
  required: ['phone_number', 'task'],
29
34
  },
@@ -49,6 +54,7 @@ class CallStartTool implements Tool {
49
54
  task: input.task as string,
50
55
  context: input.context as string | undefined,
51
56
  conversationId: context.conversationId,
57
+ callerIdentityMode: input.caller_identity_mode as 'assistant_number' | 'user_number' | undefined,
52
58
  });
53
59
 
54
60
  if (!result.ok) {
@@ -61,6 +67,8 @@ class CallStartTool implements Tool {
61
67
  ` Call Session ID: ${result.session.id}`,
62
68
  ` Call SID: ${result.callSid}`,
63
69
  ` To: ${result.session.toNumber}`,
70
+ ` From: ${result.session.fromNumber}`,
71
+ ` Caller Identity Mode: ${result.callerIdentityMode}`,
64
72
  ` Status: initiated`,
65
73
  '',
66
74
  'The AI voice assistant is now placing the call. Use call_status to check progress.',
@@ -0,0 +1,21 @@
1
+ import type { ExecutionTarget } from './types.js';
2
+ import { getTool } from './registry.js';
3
+
4
+ export function resolveExecutionTarget(toolName: string): ExecutionTarget {
5
+ const tool = getTool(toolName);
6
+ // Manifest-declared execution target is authoritative — check it first so
7
+ // skill tools with host_/computer_use_ prefixes aren't mis-classified.
8
+ if (tool?.executionTarget) {
9
+ return tool.executionTarget;
10
+ }
11
+ // Check the tool's executionMode metadata — proxy tools run on the connected
12
+ // client (host), not inside the sandbox.
13
+ if (tool?.executionMode === 'proxy') {
14
+ return 'host';
15
+ }
16
+ // Prefix heuristics for core tools that don't declare an explicit target.
17
+ if (toolName.startsWith('host_') || toolName.startsWith('computer_use_')) {
18
+ return 'host';
19
+ }
20
+ return 'sandbox';
21
+ }
@@ -0,0 +1,49 @@
1
+ import type { ToolExecutionResult } from './types.js';
2
+
3
+ const TIMEOUT_SENTINEL = Symbol('tool-timeout');
4
+
5
+ export const DEFAULT_TOOL_TIMEOUT_SEC = 120;
6
+
7
+ /**
8
+ * Convert a config-provided seconds value to a safe milliseconds value,
9
+ * falling back to the default if the input is NaN, non-finite, zero, or negative.
10
+ */
11
+ export function safeTimeoutMs(sec: unknown): number {
12
+ const n = Number(sec);
13
+ if (!Number.isFinite(n) || n <= 0) {
14
+ return DEFAULT_TOOL_TIMEOUT_SEC * 1000;
15
+ }
16
+ return n * 1000;
17
+ }
18
+
19
+ /**
20
+ * Race a tool execution promise against a timeout. Returns a timeout error
21
+ * result instead of throwing so the agent loop can continue gracefully.
22
+ */
23
+ export async function executeWithTimeout(
24
+ promise: Promise<ToolExecutionResult>,
25
+ timeoutMs: number,
26
+ toolName: string,
27
+ ): Promise<ToolExecutionResult> {
28
+ // Guard against NaN/invalid values that would cause setTimeout to fire immediately
29
+ const safeMs = Number.isFinite(timeoutMs) && timeoutMs > 0
30
+ ? timeoutMs
31
+ : DEFAULT_TOOL_TIMEOUT_SEC * 1000;
32
+ let timeoutHandle: ReturnType<typeof setTimeout>;
33
+ const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
34
+ timeoutHandle = setTimeout(() => resolve(TIMEOUT_SENTINEL), safeMs);
35
+ });
36
+ try {
37
+ const result = await Promise.race([promise, timeoutPromise]);
38
+ if (result === TIMEOUT_SENTINEL) {
39
+ const sec = Math.round(safeMs / 1000);
40
+ return {
41
+ content: `Tool "${toolName}" timed out after ${sec}s. The operation may still be running in the background. Consider increasing timeouts.toolExecutionTimeoutSec in the config.`,
42
+ isError: true,
43
+ };
44
+ }
45
+ return result;
46
+ } finally {
47
+ clearTimeout(timeoutHandle!);
48
+ }
49
+ }
@@ -1,8 +1,7 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs';
2
2
  import { getTool, getAllTools } from './registry.js';
3
- import type { ExecutionTarget, Tool, ToolContext, ToolExecutionResult, ToolLifecycleEvent } from './types.js';
3
+ import type { ToolContext, ToolExecutionResult, ToolLifecycleEvent } from './types.js';
4
4
  import { RiskLevel } from '../permissions/types.js';
5
- import type { PolicyContext } from '../permissions/types.js';
6
5
  import { check, classifyRisk, generateAllowlistOptions, generateScopeOptions } from '../permissions/checker.js';
7
6
  import { addRule } from '../permissions/trust-store.js';
8
7
  import { PermissionPrompter } from '../permissions/prompter.js';
@@ -18,6 +17,9 @@ import { scanText, redactSecrets } from '../security/secret-scanner.js';
18
17
  import { redactSensitiveFields } from '../security/redaction.js';
19
18
  import { getHookManager } from '../hooks/manager.js';
20
19
  import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
20
+ import { safeTimeoutMs, executeWithTimeout } from './execution-timeout.js';
21
+ import { buildPolicyContext } from './policy-context.js';
22
+ import { resolveExecutionTarget } from './execution-target.js';
21
23
 
22
24
  const log = getLogger('tool-executor');
23
25
 
@@ -196,7 +198,7 @@ export class ToolExecutor {
196
198
  }
197
199
 
198
200
  // Need user approval
199
- const allowlistOptions = generateAllowlistOptions(name, input);
201
+ const allowlistOptions = await generateAllowlistOptions(name, input);
200
202
  const scopeOptions = generateScopeOptions(context.workingDir, name);
201
203
 
202
204
  // Compute preview diff for file tools so the user sees what will change
@@ -253,11 +255,6 @@ export class ToolExecutor {
253
255
  sandboxed,
254
256
  context.conversationId,
255
257
  executionTarget,
256
- policyContext?.principal ? {
257
- kind: policyContext.principal.kind,
258
- id: policyContext.principal.id,
259
- version: policyContext.principal.version,
260
- } : undefined,
261
258
  persistentDecisionsAllowed,
262
259
  );
263
260
 
@@ -325,9 +322,6 @@ export class ToolExecutor {
325
322
  ) {
326
323
  const ruleOptions: {
327
324
  allowHighRisk?: boolean;
328
- principalKind?: string;
329
- principalId?: string;
330
- principalVersion?: string;
331
325
  executionTarget?: string;
332
326
  } = {};
333
327
 
@@ -335,19 +329,6 @@ export class ToolExecutor {
335
329
  ruleOptions.allowHighRisk = true;
336
330
  }
337
331
 
338
- // Capture the principal context from the tool so the saved rule
339
- // is scoped to the specific skill/version that was approved.
340
- if (policyContext?.principal) {
341
- if (policyContext.principal.kind != null) {
342
- ruleOptions.principalKind = policyContext.principal.kind;
343
- }
344
- if (policyContext.principal.id != null) {
345
- ruleOptions.principalId = policyContext.principal.id;
346
- }
347
- if (policyContext.principal.version != null) {
348
- ruleOptions.principalVersion = policyContext.principal.version;
349
- }
350
- }
351
332
  if (policyContext?.executionTarget != null) {
352
333
  ruleOptions.executionTarget = policyContext.executionTarget;
353
334
  }
@@ -393,11 +374,7 @@ export class ToolExecutor {
393
374
  const rawTimeoutSec = getConfig().timeouts.toolExecutionTimeoutSec;
394
375
  const toolTimeoutMs = safeTimeoutMs(rawTimeoutSec);
395
376
 
396
- // Enrich context with principal so tools (e.g. claude_code) can
397
- // forward it through sub-tool confirmation requests.
398
- const execContext = policyContext?.principal
399
- ? { ...context, principal: policyContext.principal }
400
- : context;
377
+ const execContext = context;
401
378
 
402
379
  if (tool.executionMode === 'proxy') {
403
380
  if (!context.proxyToolResolver) {
@@ -589,7 +566,6 @@ export class ToolExecutor {
589
566
  undefined, // not sandboxed
590
567
  context.conversationId,
591
568
  executionTarget,
592
- undefined, // no principal
593
569
  false, // no persistent decisions
594
570
  );
595
571
 
@@ -756,111 +732,6 @@ export function isSideEffectTool(toolName: string, input?: Record<string, unknow
756
732
  return false;
757
733
  }
758
734
 
759
- const TIMEOUT_SENTINEL = Symbol('tool-timeout');
760
-
761
- const DEFAULT_TOOL_TIMEOUT_SEC = 120;
762
-
763
- /**
764
- * Convert a config-provided seconds value to a safe milliseconds value,
765
- * falling back to the default if the input is NaN, non-finite, zero, or negative.
766
- */
767
- function safeTimeoutMs(sec: unknown): number {
768
- const n = Number(sec);
769
- if (!Number.isFinite(n) || n <= 0) {
770
- return DEFAULT_TOOL_TIMEOUT_SEC * 1000;
771
- }
772
- return n * 1000;
773
- }
774
-
775
- /**
776
- * Race a tool execution promise against a timeout. Returns a timeout error
777
- * result instead of throwing so the agent loop can continue gracefully.
778
- */
779
- async function executeWithTimeout(
780
- promise: Promise<ToolExecutionResult>,
781
- timeoutMs: number,
782
- toolName: string,
783
- ): Promise<ToolExecutionResult> {
784
- // Guard against NaN/invalid values that would cause setTimeout to fire immediately
785
- const safeMs = Number.isFinite(timeoutMs) && timeoutMs > 0
786
- ? timeoutMs
787
- : DEFAULT_TOOL_TIMEOUT_SEC * 1000;
788
- let timeoutHandle: ReturnType<typeof setTimeout>;
789
- const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) => {
790
- timeoutHandle = setTimeout(() => resolve(TIMEOUT_SENTINEL), safeMs);
791
- });
792
- try {
793
- const result = await Promise.race([promise, timeoutPromise]);
794
- if (result === TIMEOUT_SENTINEL) {
795
- const sec = Math.round(safeMs / 1000);
796
- return {
797
- content: `Tool "${toolName}" timed out after ${sec}s. The operation may still be running in the background. Consider increasing timeouts.toolExecutionTimeoutSec in the config.`,
798
- isError: true,
799
- };
800
- }
801
- return result;
802
- } finally {
803
- clearTimeout(timeoutHandle!);
804
- }
805
- }
806
-
807
- /**
808
- * Build a PolicyContext from tool metadata and execution context. Skill-origin
809
- * tools carry a principal identifying the owning skill. When executing within
810
- * a task run, ephemeral permission rules are included so pre-approved tools
811
- * are auto-allowed without prompting.
812
- */
813
- function buildPolicyContext(tool: Tool, context?: ToolContext): PolicyContext | undefined {
814
- const ephemeralRules = context?.taskRunId
815
- ? getTaskRunRules(context.taskRunId)
816
- : undefined;
817
-
818
- if (tool.origin === 'skill') {
819
- return {
820
- principal: {
821
- kind: 'skill',
822
- id: tool.ownerSkillId,
823
- version: tool.ownerSkillVersionHash,
824
- },
825
- executionTarget: tool.executionTarget,
826
- ephemeralRules: ephemeralRules?.length ? ephemeralRules : undefined,
827
- };
828
- }
829
-
830
- // For non-skill tools in a task run, create a context with task principal
831
- // and ephemeral rules so pre-approved tools are honored.
832
- if (context?.taskRunId && ephemeralRules?.length) {
833
- return {
834
- principal: {
835
- kind: 'task',
836
- id: context.taskRunId,
837
- },
838
- ephemeralRules,
839
- };
840
- }
841
-
842
- return undefined;
843
- }
844
-
845
- function resolveExecutionTarget(toolName: string): ExecutionTarget {
846
- const tool = getTool(toolName);
847
- // Manifest-declared execution target is authoritative — check it first so
848
- // skill tools with host_/computer_use_ prefixes aren't mis-classified.
849
- if (tool?.executionTarget) {
850
- return tool.executionTarget;
851
- }
852
- // Check the tool's executionMode metadata — proxy tools run on the connected
853
- // client (host), not inside the sandbox.
854
- if (tool?.executionMode === 'proxy') {
855
- return 'host';
856
- }
857
- // Prefix heuristics for core tools that don't declare an explicit target.
858
- if (toolName.startsWith('host_') || toolName.startsWith('computer_use_')) {
859
- return 'host';
860
- }
861
- return 'sandbox';
862
- }
863
-
864
735
  /**
865
736
  * Sanitize tool inputs before they are emitted in lifecycle events and hooks.
866
737
  * Applies recursive field-level redaction for known-sensitive keys.
@@ -5,31 +5,12 @@ import { registerTool } from '../registry.js';
5
5
  import { getConfig } from '../../config/loader.js';
6
6
  import { getSecureKey } from '../../security/secure-keys.js';
7
7
  import { getLogger } from '../../util/logger.js';
8
+ import { getHttpRetryDelay, sleep, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY_MS } from '../../util/retry.js';
8
9
 
9
10
  const log = getLogger('web-search');
10
11
 
11
12
  const BRAVE_API_URL = 'https://api.search.brave.com/res/v1/web/search';
12
13
  const PERPLEXITY_API_URL = 'https://api.perplexity.ai/chat/completions';
13
- const RATE_LIMIT_MAX_RETRIES = 3;
14
- const RATE_LIMIT_BASE_DELAY_MS = 1000;
15
-
16
- /**
17
- * Parse a Retry-After header value into milliseconds.
18
- * RFC 7231 allows either delta-seconds (e.g. "120") or an HTTP-date
19
- * (e.g. "Tue, 17 Feb 2026 12:00:00 GMT"). Returns undefined if unparseable.
20
- */
21
- function parseRetryAfterMs(value: string): number | undefined {
22
- const seconds = Number(value);
23
- if (!isNaN(seconds)) {
24
- return seconds * 1000;
25
- }
26
- // Try HTTP-date format — Date.parse handles RFC 2822 / IMF-fixdate
27
- const dateMs = Date.parse(value);
28
- if (!isNaN(dateMs)) {
29
- return Math.max(0, dateMs - Date.now());
30
- }
31
- return undefined;
32
- }
33
14
 
34
15
  type WebSearchProvider = 'perplexity' | 'brave';
35
16
 
@@ -148,7 +129,7 @@ async function executeBraveSearch(
148
129
 
149
130
  const url = `${BRAVE_API_URL}?${params.toString()}`;
150
131
 
151
- for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
132
+ for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
152
133
  const response = await fetch(url, {
153
134
  headers: {
154
135
  'Accept': 'application/json',
@@ -169,12 +150,10 @@ async function executeBraveSearch(
169
150
  return { content: 'Error: Invalid or expired Brave Search API key', isError: true };
170
151
  }
171
152
 
172
- if (response.status === 429 && attempt < RATE_LIMIT_MAX_RETRIES) {
173
- const retryAfter = response.headers.get('retry-after');
174
- const parsed = retryAfter ? parseRetryAfterMs(retryAfter) : undefined;
175
- const delayMs = parsed ?? RATE_LIMIT_BASE_DELAY_MS * Math.pow(2, attempt);
153
+ if (response.status === 429 && attempt < DEFAULT_MAX_RETRIES) {
154
+ const delayMs = getHttpRetryDelay(response, attempt, DEFAULT_BASE_DELAY_MS);
176
155
  log.warn({ attempt: attempt + 1, delayMs }, 'Brave Search rate limited, retrying');
177
- await new Promise(resolve => setTimeout(resolve, delayMs));
156
+ await sleep(delayMs);
178
157
  continue;
179
158
  }
180
159
 
@@ -192,7 +171,7 @@ async function executePerplexitySearch(
192
171
  query: string,
193
172
  apiKey: string,
194
173
  ): Promise<ToolExecutionResult> {
195
- for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
174
+ for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
196
175
  const response = await fetch(PERPLEXITY_API_URL, {
197
176
  method: 'POST',
198
177
  headers: {
@@ -218,12 +197,10 @@ async function executePerplexitySearch(
218
197
  return { content: 'Error: Invalid or expired Perplexity API key', isError: true };
219
198
  }
220
199
 
221
- if (response.status === 429 && attempt < RATE_LIMIT_MAX_RETRIES) {
222
- const retryAfter = response.headers.get('retry-after');
223
- const parsed = retryAfter ? parseRetryAfterMs(retryAfter) : undefined;
224
- const delayMs = parsed ?? RATE_LIMIT_BASE_DELAY_MS * Math.pow(2, attempt);
200
+ if (response.status === 429 && attempt < DEFAULT_MAX_RETRIES) {
201
+ const delayMs = getHttpRetryDelay(response, attempt, DEFAULT_BASE_DELAY_MS);
225
202
  log.warn({ attempt: attempt + 1, delayMs }, 'Perplexity rate limited, retrying');
226
- await new Promise(resolve => setTimeout(resolve, delayMs));
203
+ await sleep(delayMs);
227
204
  continue;
228
205
  }
229
206
 
@@ -0,0 +1,29 @@
1
+ import type { PolicyContext } from '../permissions/types.js';
2
+ import type { Tool, ToolContext } from './types.js';
3
+ import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
4
+
5
+ /**
6
+ * Build a PolicyContext from tool metadata and execution context.
7
+ * When executing within a task run, ephemeral permission rules are
8
+ * included so pre-approved tools are auto-allowed without prompting.
9
+ */
10
+ export function buildPolicyContext(tool: Tool, context?: ToolContext): PolicyContext | undefined {
11
+ const ephemeralRules = context?.taskRunId
12
+ ? getTaskRunRules(context.taskRunId)
13
+ : undefined;
14
+
15
+ if (tool.origin === 'skill') {
16
+ return {
17
+ executionTarget: tool.executionTarget,
18
+ ephemeralRules: ephemeralRules?.length ? ephemeralRules : undefined,
19
+ };
20
+ }
21
+
22
+ if (context?.taskRunId && ephemeralRules?.length) {
23
+ return {
24
+ ephemeralRules,
25
+ };
26
+ }
27
+
28
+ return undefined;
29
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ToolContext, ToolExecutionResult } from '../types.js';
2
2
  import { updateSchedule, formatLocalDate, describeCronExpression } from '../../schedule/schedule-store.js';
3
- import { normalizeScheduleSyntax, type ScheduleSyntax } from '../../schedule/recurrence-types.js';
3
+ import { normalizeScheduleSyntax, detectScheduleSyntax, type ScheduleSyntax } from '../../schedule/recurrence-types.js';
4
4
  import { validateRruleSetLines } from '../../schedule/recurrence-engine.js';
5
5
 
6
6
  export async function executeScheduleUpdate(
@@ -31,9 +31,16 @@ export async function executeScheduleUpdate(
31
31
  updates.expression = resolved.expression;
32
32
  } else if (input.expression !== undefined) {
33
33
  updates.expression = input.expression;
34
+ const detected = detectScheduleSyntax(input.expression as string);
35
+ if (detected) updates.syntax = detected;
34
36
  } else if (input.cron_expression !== undefined) {
35
37
  updates.cronExpression = input.cron_expression;
36
38
  }
39
+ // When only syntax is provided (no expression), normalizeScheduleSyntax returns null
40
+ // but we still need to persist the explicit syntax value.
41
+ if (input.syntax !== undefined && updates.syntax === undefined) {
42
+ updates.syntax = input.syntax;
43
+ }
37
44
  }
38
45
 
39
46
  if (Object.keys(updates).length === 0) {
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { getLogger } from '../../util/logger.js';
5
5
  import { IntegrityError } from '../../util/errors.js';
6
+ import { PromiseGuard } from '../../util/promise-guard.js';
6
7
  import { Parser, Language, type Node as TSNode } from 'web-tree-sitter';
7
8
 
8
9
  const log = getLogger('shell-parser');
@@ -73,7 +74,7 @@ function verifyWasmChecksum(filePath: string, label: string): void {
73
74
  }
74
75
 
75
76
  let parserInstance: Parser | null = null;
76
- let initPromise: Promise<void> | null = null;
77
+ const initGuard = new PromiseGuard<void>();
77
78
 
78
79
  /**
79
80
  * Locate a WASM file from a dependency package.
@@ -105,27 +106,24 @@ function findWasmPath(pkg: string, file: string): string {
105
106
  async function ensureParser(): Promise<Parser> {
106
107
  if (parserInstance) return parserInstance;
107
108
 
108
- if (!initPromise) {
109
- initPromise = (async () => {
110
- const treeSitterWasm = findWasmPath('web-tree-sitter', 'web-tree-sitter.wasm');
111
- const bashWasmPath = findWasmPath('tree-sitter-bash', 'tree-sitter-bash.wasm');
109
+ await initGuard.run(async () => {
110
+ const treeSitterWasm = findWasmPath('web-tree-sitter', 'web-tree-sitter.wasm');
111
+ const bashWasmPath = findWasmPath('tree-sitter-bash', 'tree-sitter-bash.wasm');
112
112
 
113
- verifyWasmChecksum(treeSitterWasm, 'web-tree-sitter.wasm');
114
- verifyWasmChecksum(bashWasmPath, 'tree-sitter-bash.wasm');
113
+ verifyWasmChecksum(treeSitterWasm, 'web-tree-sitter.wasm');
114
+ verifyWasmChecksum(bashWasmPath, 'tree-sitter-bash.wasm');
115
115
 
116
- await Parser.init({
117
- locateFile: () => treeSitterWasm,
118
- });
116
+ await Parser.init({
117
+ locateFile: () => treeSitterWasm,
118
+ });
119
119
 
120
- const Bash = await Language.load(bashWasmPath);
121
- const parser = new Parser();
122
- parser.setLanguage(Bash);
123
- parserInstance = parser;
124
- log.info('Shell parser initialized (web-tree-sitter + bash, checksums verified)');
125
- })();
126
- }
120
+ const Bash = await Language.load(bashWasmPath);
121
+ const parser = new Parser();
122
+ parser.setLanguage(Bash);
123
+ parserInstance = parser;
124
+ log.info('Shell parser initialized (web-tree-sitter + bash, checksums verified)');
125
+ });
127
126
 
128
- await initPromise;
129
127
  return parserInstance!;
130
128
  }
131
129
 
@@ -1,4 +1,4 @@
1
- import type { RiskLevel, AllowlistOption, ScopeOption, ToolPrincipalKind } from '../permissions/types.js';
1
+ import type { RiskLevel, AllowlistOption, ScopeOption } from '../permissions/types.js';
2
2
  import type { ToolDefinition, ContentBlock } from '../providers/types.js';
3
3
  import type { SecretPromptResult } from '../permissions/secret-prompter.js';
4
4
 
@@ -12,12 +12,6 @@ interface ToolLifecycleEventBase {
12
12
  conversationId: string;
13
13
  requestId?: string;
14
14
  executionTarget?: ExecutionTarget;
15
- /** Security principal kind (e.g. 'core', 'skill', or 'task'). */
16
- principalKind?: ToolPrincipalKind;
17
- /** Security principal ID (skill ID when principalKind is 'skill'; task ID when 'task'). */
18
- principalId?: string;
19
- /** Content-hash of the principal's source at invocation time. */
20
- principalVersion?: string;
21
15
  }
22
16
 
23
17
  export interface ToolExecutionStartEvent extends ToolLifecycleEventBase {
@@ -109,16 +103,13 @@ export interface ToolContext {
109
103
  proxyToolResolver?: ProxyToolResolver;
110
104
  /** When set, only tools in this set may execute. Tools outside the set are blocked with an error. */
111
105
  allowedToolNames?: Set<string>;
112
- /** Principal that owns this tool invocation (set by executor from tool metadata). */
113
- principal?: { kind?: string; id?: string; version?: string };
114
106
  /** Request user confirmation for a sub-tool operation (used by claude_code tool). */
115
107
  requestConfirmation?: (req: {
116
108
  toolName: string;
117
109
  input: Record<string, unknown>;
118
110
  riskLevel: string;
119
111
  executionTarget?: ExecutionTarget;
120
- /** Principal that owns the parent tool invocation. */
121
- principal?: { kind?: string; id?: string; version?: string };
112
+ principal?: string;
122
113
  }) => Promise<{ decision: 'allow' | 'deny' }>;
123
114
  /** Prompt the user for a secret value via native SecureField UI. */
124
115
  requestSecret?: (params: {
@@ -141,6 +132,8 @@ export interface ToolContext {
141
132
  forcePromptSideEffects?: boolean;
142
133
  /** Approval callback for proxy policy decisions that require user confirmation. */
143
134
  proxyApprovalCallback?: import('./network/script-proxy/types.js').ProxyApprovalCallback;
135
+ /** Optional principal identifier propagated to sub-tool confirmation flows. */
136
+ principal?: string;
144
137
  }
145
138
 
146
139
  export interface DiffInfo {