@vellumai/assistant 0.3.27 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
package/ARCHITECTURE.md CHANGED
@@ -18,7 +18,7 @@ This document owns assistant-runtime architecture details. The repo-level archit
18
18
  - The same resolver is used by:
19
19
  - `/channels/inbound` (Telegram/SMS/WhatsApp path) before run orchestration.
20
20
  - Inbound Twilio voice setup (`RelayConnection.handleSetup`) to seed call-time actor context.
21
- - Runtime channel runs pass this as `guardianContext`, and session runtime assembly injects `<guardian_context>` into provider-facing prompts.
21
+ - Runtime channel runs pass this as `guardianContext`, and session runtime assembly injects `<inbound_actor_context>` (via `inboundActorContextFromGuardian()`) into provider-facing prompts.
22
22
  - Voice calls mirror the same prompt contract: `CallController` receives guardian context on setup and refreshes it immediately after successful voice challenge verification, so the first post-verification turn is grounded as `actor_role: guardian`.
23
23
  - Voice-specific behavior (DTMF/speech verification flow, relay state machine) remains voice-local; only actor-role resolution is shared.
24
24
 
@@ -62,6 +62,41 @@ All guardian approval decisions — regardless of how they arrive — route thro
62
62
  | `src/daemon/ipc-contract/guardian-actions.ts` | IPC message type definitions for guardian action requests/responses |
63
63
  | `src/runtime/channel-approval-types.ts` | Channel-facing approval action types and `toApprovalActionOptions` bridge |
64
64
 
65
+ ### Canonical Guardian Request System
66
+
67
+ The canonical guardian request system provides a channel-agnostic, unified domain for all guardian approval and question flows. It replaces the fragmented per-channel storage with a single source of truth that works identically for voice calls, Telegram/SMS/WhatsApp, and desktop UI.
68
+
69
+ **Architecture layers:**
70
+
71
+ 1. **Canonical domain (single source of truth):** All guardian requests — tool approvals, pending questions, access requests — are persisted in the `canonical_guardian_requests` table (`src/memory/canonical-guardian-store.js`). Each request has a unique ID, a short human-readable request code, and a status that follows a CAS (compare-and-swap) lifecycle: `pending` -> `approved` | `denied` | `expired` | `cancelled`. Deliveries (notifications sent to guardians) are tracked in `canonical_guardian_deliveries`.
72
+
73
+ 2. **Unified apply primitive (single write path):** `applyCanonicalGuardianDecision()` in `src/approvals/guardian-decision-primitive.ts` is the single write path for all guardian decisions. It enforces identity validation, expiry checks, CAS resolution, `approve_always` downgrade (guardian-on-behalf invariant), kind-specific resolver dispatch via the resolver registry, and scoped grant minting. All callers — HTTP API, IPC handlers, inbound channel router, desktop session — route decisions through this function.
74
+
75
+ 3. **Shared reply router (priority-ordered routing):** `routeGuardianReply()` in `src/runtime/guardian-reply-router.ts` provides a single entry point for all inbound guardian reply processing across channels. It routes through a priority-ordered pipeline: (a) deterministic callback parsing (button presses with `apr:<requestId>:<action>`), (b) request code parsing (6-char alphanumeric prefix), (c) NL classification via the conversational approval engine. All decisions flow through `applyCanonicalGuardianDecision`.
76
+
77
+ 4. **Deterministic API (prompt listing and decision endpoints):** Desktop clients and API consumers use `GET /v1/guardian-actions/pending` and `POST /v1/guardian-actions/decision` (HTTP) or the equivalent IPC messages. These endpoints surface canonical requests alongside legacy pending interactions and channel approval records, with deduplication to avoid double-rendering.
78
+
79
+ 5. **Dual-mode (deterministic + conversational):** Guardians can respond via structured button UIs (deterministic path) or free-text conversation (NL path). Both paths converge on the same canonical primitive. Code-only messages (just a request code without decision text) return clarification instead of auto-approving. Disambiguation with multiple pending requests stays fail-closed — no auto-resolve when the target is ambiguous.
80
+
81
+ **Resolver registry:** Kind-specific resolvers (`src/approvals/guardian-request-resolvers.ts`) handle side effects after CAS resolution. Built-in resolvers: `tool_approval` (channel/desktop approval path) and `pending_question` (voice call question path). New request kinds register resolvers without touching the core primitive.
82
+
83
+ **Expiry sweeps:** Three complementary sweeps run on 60-second intervals to clean up stale requests:
84
+ - `src/calls/guardian-action-sweep.ts` — voice call guardian action requests
85
+ - `src/runtime/routes/guardian-expiry-sweep.ts` — channel guardian approval requests
86
+ - `src/runtime/routes/canonical-guardian-expiry-sweep.ts` — canonical guardian requests (CAS-safe)
87
+
88
+ **Key source files:**
89
+
90
+ | File | Purpose |
91
+ |------|---------|
92
+ | `src/memory/canonical-guardian-store.ts` | Canonical request and delivery persistence (CRUD, CAS resolve, list with filters) |
93
+ | `src/approvals/guardian-decision-primitive.ts` | Unified decision primitive: `applyCanonicalGuardianDecision` (canonical) and `applyGuardianDecision` (legacy) |
94
+ | `src/approvals/guardian-request-resolvers.ts` | Resolver registry: kind-specific side-effect dispatch after CAS resolution |
95
+ | `src/runtime/guardian-reply-router.ts` | Shared inbound router: callback -> code -> NL classification pipeline |
96
+ | `src/runtime/routes/guardian-action-routes.ts` | HTTP endpoints for prompt listing and decision submission |
97
+ | `src/daemon/handlers/guardian-actions.ts` | IPC handlers for desktop socket clients |
98
+ | `src/runtime/routes/canonical-guardian-expiry-sweep.ts` | Canonical request expiry sweep |
99
+
65
100
  ### Outbound Guardian Verification (HTTP Endpoints)
66
101
 
67
102
  Guardian verification can be initiated through gateway HTTP endpoints (which forward to runtime handlers) as an alternative to the legacy IPC-only flow. This enables chat-first verification where the assistant guides the user through guardian setup via normal conversation.
@@ -1874,6 +1909,18 @@ Connected channels are resolved at signal emission time: vellum is always includ
1874
1909
 
1875
1910
 
1876
1911
 
1912
+ ### Sensitive Tool Output Placeholder Substitution
1913
+
1914
+ Some tool outputs contain values that must reach the user's final reply but should never be visible to the LLM (e.g., invite tokens). The system handles this with a three-stage pipeline:
1915
+
1916
+ 1. **Directive extraction** (`src/tools/sensitive-output-placeholders.ts`): Tool output may include `<vellum-sensitive-output kind="invite_code" value="<raw>" />` directives. The executor strips directives, replaces raw values with deterministic placeholders (`VELLUM_ASSISTANT_INVITE_CODE_<shortId>`), and attaches `sensitiveBindings` metadata to the tool result.
1917
+
1918
+ 2. **Placeholder-only model context**: The agent loop stores placeholder->value bindings in a per-run `substitutionMap`. Tool results sent to the LLM contain only placeholders — the model generates conversational text referencing them without ever seeing the real values.
1919
+
1920
+ 3. **Post-generation substitution** (`src/agent/loop.ts`): Before emitting streamed `text_delta` events and before building the final `assistantMessage`, all placeholders are deterministically replaced with their real values. The substitution is chunk-safe for streaming (buffering partial placeholder prefixes across deltas).
1921
+
1922
+ Key files: `src/tools/sensitive-output-placeholders.ts`, `src/tools/executor.ts` (extraction hook), `src/agent/loop.ts` (substitution), `src/config/vellum-skills/trusted-contacts/SKILL.md` (invite flow adoption).
1923
+
1877
1924
  ### Notifications
1878
1925
 
1879
1926
  For full notification developer guidance and lifecycle details, see [`assistant/src/notifications/README.md`](src/notifications/README.md).
package/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  # Use debian as base image
2
- FROM debian:bookworm AS builder
2
+ FROM debian:trixie@sha256:3615a749858a1cba49b408fb49c37093db813321355a9ab7c1f9f4836341e9db AS builder
3
3
 
4
4
  WORKDIR /app
5
5
 
@@ -26,7 +26,7 @@ RUN bun install --frozen-lockfile
26
26
  COPY . .
27
27
 
28
28
  # Final stage
29
- FROM debian:bookworm AS runner
29
+ FROM debian:trixie@sha256:3615a749858a1cba49b408fb49c37093db813321355a9ab7c1f9f4836341e9db AS runner
30
30
 
31
31
  WORKDIR /app
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.27",
3
+ "version": "0.3.28",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -411,14 +411,18 @@ function emitStruct(s: SwiftStruct): string {
411
411
  const lines: string[] = [];
412
412
 
413
413
  if (s.doc) {
414
- lines.push(`/// ${s.doc}`);
414
+ for (const docLine of s.doc.split('\n')) {
415
+ lines.push(`/// ${docLine}`);
416
+ }
415
417
  }
416
418
 
417
419
  lines.push(`public struct ${s.name}: Codable, Sendable {`);
418
420
 
419
421
  for (const p of s.properties) {
420
422
  if (p.doc) {
421
- lines.push(` /// ${p.doc}`);
423
+ for (const docLine of p.doc.split('\n')) {
424
+ lines.push(` /// ${docLine}`);
425
+ }
422
426
  }
423
427
  lines.push(` public let ${p.swiftName}: ${p.swiftType}`);
424
428
  }
@@ -1167,4 +1167,123 @@ describe('AgentLoop', () => {
1167
1167
  expect(sentBlock).toBeDefined();
1168
1168
  expect(sentBlock!.content).toBe(smallContent);
1169
1169
  });
1170
+
1171
+ // ---------------------------------------------------------------------------
1172
+ // Sensitive output placeholder substitution tests
1173
+ // ---------------------------------------------------------------------------
1174
+
1175
+ // 32. Tool results with sensitiveBindings populate substitution map and
1176
+ // final assistant message text is resolved with real values.
1177
+ test('resolves sensitive output placeholders in final assistant message', async () => {
1178
+ const placeholder = 'VELLUM_ASSISTANT_INVITE_CODE_TEST1234';
1179
+ const realToken = 'realInviteToken999';
1180
+
1181
+ const { provider, calls } = createMockProvider([
1182
+ toolUseResponse('t1', 'bash', { command: 'create invite' }),
1183
+ // The LLM responds using the placeholder (it never saw the real token)
1184
+ textResponse(`Here is your invite link: https://t.me/bot?start=iv_${placeholder}`),
1185
+ ]);
1186
+
1187
+ const toolExecutor = async () => ({
1188
+ content: `https://t.me/bot?start=iv_${placeholder}`,
1189
+ isError: false,
1190
+ sensitiveBindings: [{ kind: 'invite_code' as const, placeholder, value: realToken }],
1191
+ });
1192
+
1193
+ const loop = new AgentLoop(provider, 'system', {}, dummyTools, toolExecutor);
1194
+ const events: AgentEvent[] = [];
1195
+ const history = await loop.run([userMessage], collectEvents(events));
1196
+
1197
+ // The final assistant message in HISTORY should retain placeholders
1198
+ // (so the model never sees real values on subsequent turns)
1199
+ const lastAssistant = history[history.length - 1];
1200
+ expect(lastAssistant.role).toBe('assistant');
1201
+ const historyTextBlock = lastAssistant.content.find(
1202
+ (b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text',
1203
+ );
1204
+ expect(historyTextBlock).toBeDefined();
1205
+ expect(historyTextBlock!.text).toContain(placeholder);
1206
+ expect(historyTextBlock!.text).not.toContain(realToken);
1207
+
1208
+ // The message_complete EVENT should also retain placeholders (persisted
1209
+ // to conversation store; real values leak on session reload otherwise)
1210
+ const completeEvents = events.filter(
1211
+ (e): e is Extract<AgentEvent, { type: 'message_complete' }> => e.type === 'message_complete',
1212
+ );
1213
+ const lastComplete = completeEvents[completeEvents.length - 1];
1214
+ const completeText = lastComplete.message.content.find(
1215
+ (b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text',
1216
+ );
1217
+ expect(completeText!.text).toContain(placeholder);
1218
+ expect(completeText!.text).not.toContain(realToken);
1219
+
1220
+ // The tool result content in provider history should contain the PLACEHOLDER,
1221
+ // NOT the raw token (model never sees the real value)
1222
+ const secondCallMessages = calls[1].messages;
1223
+ const toolResultMsg = secondCallMessages.find(
1224
+ (m) => m.role === 'user' && m.content.some((b) => b.type === 'tool_result'),
1225
+ );
1226
+ expect(toolResultMsg).toBeDefined();
1227
+ const toolResultBlock = toolResultMsg!.content.find(
1228
+ (b): b is Extract<ContentBlock, { type: 'tool_result' }> => b.type === 'tool_result',
1229
+ );
1230
+ expect(toolResultBlock!.content).toContain(placeholder);
1231
+ expect(toolResultBlock!.content).not.toContain(realToken);
1232
+ });
1233
+
1234
+ // 33. Streamed text_delta events have placeholders resolved to real values
1235
+ test('resolves sensitive output placeholders in streamed text_delta events', async () => {
1236
+ const placeholder = 'VELLUM_ASSISTANT_INVITE_CODE_STRM5678';
1237
+ const realToken = 'streamedRealToken';
1238
+
1239
+ const { provider } = createMockProvider([
1240
+ toolUseResponse('t1', 'bash', { command: 'invite' }),
1241
+ // Response text includes the placeholder
1242
+ textResponse(`Link: https://t.me/bot?start=iv_${placeholder}`),
1243
+ ]);
1244
+
1245
+ const toolExecutor = async () => ({
1246
+ content: `https://t.me/bot?start=iv_${placeholder}`,
1247
+ isError: false,
1248
+ sensitiveBindings: [{ kind: 'invite_code' as const, placeholder, value: realToken }],
1249
+ });
1250
+
1251
+ const loop = new AgentLoop(provider, 'system', {}, dummyTools, toolExecutor);
1252
+ const events: AgentEvent[] = [];
1253
+ await loop.run([userMessage], collectEvents(events));
1254
+
1255
+ // Collect all text_delta events from the final turn (after tool result)
1256
+ const textDeltas = events.filter(
1257
+ (e): e is Extract<AgentEvent, { type: 'text_delta' }> => e.type === 'text_delta',
1258
+ );
1259
+ const allStreamedText = textDeltas.map((e) => e.text).join('');
1260
+
1261
+ // Streamed text should contain the real token, not the placeholder
1262
+ expect(allStreamedText).toContain(realToken);
1263
+ expect(allStreamedText).not.toContain(placeholder);
1264
+ });
1265
+
1266
+ // 34. Without sensitive bindings, text passes through unchanged
1267
+ test('text passes through unchanged when no sensitive bindings exist', async () => {
1268
+ const { provider } = createMockProvider([
1269
+ toolUseResponse('t1', 'read_file', { path: '/test.txt' }),
1270
+ textResponse('Normal response with no placeholders.'),
1271
+ ]);
1272
+
1273
+ const toolExecutor = async () => ({
1274
+ content: 'file contents',
1275
+ isError: false,
1276
+ // No sensitiveBindings
1277
+ });
1278
+
1279
+ const loop = new AgentLoop(provider, 'system', {}, dummyTools, toolExecutor);
1280
+ const events: AgentEvent[] = [];
1281
+ const history = await loop.run([userMessage], collectEvents(events));
1282
+
1283
+ const lastAssistant = history[history.length - 1];
1284
+ const textBlock = lastAssistant.content.find(
1285
+ (b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text',
1286
+ );
1287
+ expect(textBlock!.text).toBe('Normal response with no placeholders.');
1288
+ });
1170
1289
  });
@@ -0,0 +1,107 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
6
+
7
+ import { resolveBundledDir } from '../util/bundled-asset.js';
8
+
9
+ let tempDir: string;
10
+
11
+ beforeEach(() => {
12
+ tempDir = join(tmpdir(), `bundled-asset-test-${crypto.randomUUID()}`);
13
+ mkdirSync(tempDir, { recursive: true });
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('resolveBundledDir', () => {
21
+ test('source mode: returns join(callerDir, relativePath) when callerDir is a normal path', () => {
22
+ const result = resolveBundledDir('/some/source/path', 'templates', 'templates');
23
+ expect(result).toBe(join('/some/source/path', 'templates'));
24
+ });
25
+
26
+ test('source mode: does not check existsSync for the source path', () => {
27
+ // Even if the resolved path does not exist, it returns it as-is
28
+ const result = resolveBundledDir('/nonexistent/path', 'templates', 'templates');
29
+ expect(result).toBe(join('/nonexistent/path', 'templates'));
30
+ });
31
+
32
+ describe('compiled mode (/$bunfs/ prefix)', () => {
33
+ // In compiled mode, process.execPath determines fallback locations.
34
+ // We simulate by creating real directories at the expected fallback paths.
35
+
36
+ let savedExecPath: string;
37
+
38
+ beforeEach(() => {
39
+ savedExecPath = process.execPath;
40
+ });
41
+
42
+ afterEach(() => {
43
+ process.execPath = savedExecPath;
44
+ });
45
+
46
+ test('prefers Contents/Resources/<bundleName> when it exists', () => {
47
+ // Simulate macOS .app bundle: binary at Contents/MacOS/vellum-daemon
48
+ const macosDir = join(tempDir, 'Contents', 'MacOS');
49
+ const resourcesDir = join(tempDir, 'Contents', 'Resources');
50
+ mkdirSync(macosDir, { recursive: true });
51
+ mkdirSync(join(resourcesDir, 'templates'), { recursive: true });
52
+
53
+ process.execPath = join(macosDir, 'vellum-daemon');
54
+
55
+ const result = resolveBundledDir('/$bunfs/root/src/config', 'templates', 'templates');
56
+ expect(result).toBe(join(resourcesDir, 'templates'));
57
+ });
58
+
59
+ test('falls back to <execDir>/<bundleName> when Resources does not exist', () => {
60
+ // Simulate standalone binary deployment (no .app bundle)
61
+ const binDir = join(tempDir, 'bin');
62
+ mkdirSync(join(binDir, 'templates'), { recursive: true });
63
+
64
+ process.execPath = join(binDir, 'vellum-daemon');
65
+
66
+ const result = resolveBundledDir('/$bunfs/root/src/config', 'templates', 'templates');
67
+ expect(result).toBe(join(binDir, 'templates'));
68
+ });
69
+
70
+ test('falls back to source path when neither Resources nor execDir have the asset', () => {
71
+ const binDir = join(tempDir, 'bin');
72
+ mkdirSync(binDir, { recursive: true });
73
+ // Don't create any asset directories
74
+
75
+ process.execPath = join(binDir, 'vellum-daemon');
76
+
77
+ const result = resolveBundledDir('/$bunfs/root/src/config', 'templates', 'templates');
78
+ expect(result).toBe(join('/$bunfs/root/src/config', 'templates'));
79
+ });
80
+
81
+ test('Resources path takes priority over execDir path when both exist', () => {
82
+ const macosDir = join(tempDir, 'Contents', 'MacOS');
83
+ const resourcesDir = join(tempDir, 'Contents', 'Resources');
84
+ mkdirSync(macosDir, { recursive: true });
85
+ mkdirSync(join(resourcesDir, 'hook-templates'), { recursive: true });
86
+ // Also create at execDir level
87
+ mkdirSync(join(macosDir, 'hook-templates'), { recursive: true });
88
+
89
+ process.execPath = join(macosDir, 'vellum-daemon');
90
+
91
+ const result = resolveBundledDir('/$bunfs/root/src/hooks', '../../hook-templates', 'hook-templates');
92
+ expect(result).toBe(join(resourcesDir, 'hook-templates'));
93
+ });
94
+
95
+ test('works with different bundleName values', () => {
96
+ const macosDir = join(tempDir, 'Contents', 'MacOS');
97
+ const resourcesDir = join(tempDir, 'Contents', 'Resources');
98
+ mkdirSync(macosDir, { recursive: true });
99
+ mkdirSync(join(resourcesDir, 'prebuilt'), { recursive: true });
100
+
101
+ process.execPath = join(macosDir, 'vellum-daemon');
102
+
103
+ const result = resolveBundledDir('/$bunfs/root/src/home-base/prebuilt', '.', 'prebuilt');
104
+ expect(result).toBe(join(resourcesDir, 'prebuilt'));
105
+ });
106
+ });
107
+ });