@vellumai/assistant 0.6.1 → 0.6.2

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 (115) hide show
  1. package/docker-entrypoint.sh +12 -2
  2. package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
  3. package/openapi.yaml +1 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-event-hub.test.ts +30 -0
  6. package/src/__tests__/checker.test.ts +104 -170
  7. package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
  8. package/src/__tests__/context-overflow-approval.test.ts +5 -5
  9. package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
  10. package/src/__tests__/conversation-directories-parse.test.ts +105 -0
  11. package/src/__tests__/credential-execution-approval-bridge.test.ts +0 -2
  12. package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
  13. package/src/__tests__/inline-command-runner.test.ts +7 -5
  14. package/src/__tests__/log-export-workspace.test.ts +190 -0
  15. package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
  16. package/src/__tests__/navigate-settings-tab.test.ts +14 -1
  17. package/src/__tests__/notification-broadcaster.test.ts +65 -0
  18. package/src/__tests__/onboarding-template-contract.test.ts +5 -4
  19. package/src/__tests__/pkb-autoinject.test.ts +96 -0
  20. package/src/__tests__/require-fresh-approval.test.ts +0 -2
  21. package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
  22. package/src/__tests__/terminal-sandbox.test.ts +1 -1
  23. package/src/__tests__/terminal-tools.test.ts +2 -5
  24. package/src/__tests__/test-preload.ts +14 -0
  25. package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
  27. package/src/__tests__/tool-executor.test.ts +0 -1
  28. package/src/__tests__/transport-hints-queue.test.ts +77 -0
  29. package/src/__tests__/trust-store.test.ts +4 -4
  30. package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
  31. package/src/__tests__/workspace-policy.test.ts +2 -7
  32. package/src/agent/loop.ts +0 -29
  33. package/src/channels/types.ts +5 -0
  34. package/src/cli/__tests__/run-assistant-command.ts +34 -7
  35. package/src/cli/__tests__/unknown-command.test.ts +33 -0
  36. package/src/cli/commands/default-action.ts +68 -1
  37. package/src/cli/commands/oauth/__tests__/connect.test.ts +27 -0
  38. package/src/cli/commands/oauth/connect.ts +11 -0
  39. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  40. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  41. package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
  42. package/src/cli/program.ts +9 -2
  43. package/src/config/assistant-feature-flags.ts +59 -55
  44. package/src/config/bundled-skills/app-builder/SKILL.md +87 -4
  45. package/src/config/bundled-skills/gmail/SKILL.md +11 -6
  46. package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
  47. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
  48. package/src/config/bundled-skills/settings/TOOLS.json +1 -1
  49. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
  50. package/src/config/feature-flag-registry.json +2 -2
  51. package/src/config/schemas/services.ts +8 -0
  52. package/src/credential-execution/approval-bridge.ts +0 -1
  53. package/src/credential-execution/managed-catalog.ts +3 -7
  54. package/src/daemon/config-watcher.ts +6 -2
  55. package/src/daemon/context-overflow-approval.ts +0 -1
  56. package/src/daemon/conversation-agent-loop.ts +33 -12
  57. package/src/daemon/conversation-attachments.ts +0 -1
  58. package/src/daemon/conversation-messaging.ts +3 -0
  59. package/src/daemon/conversation-process.ts +18 -2
  60. package/src/daemon/conversation-queue-manager.ts +8 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +64 -7
  62. package/src/daemon/conversation-surfaces.ts +65 -0
  63. package/src/daemon/conversation-tool-setup.ts +0 -3
  64. package/src/daemon/conversation.ts +3 -5
  65. package/src/daemon/handlers/conversations.ts +2 -1
  66. package/src/daemon/handlers/shared.ts +7 -0
  67. package/src/daemon/lifecycle.ts +21 -1
  68. package/src/daemon/message-types/conversations.ts +4 -0
  69. package/src/daemon/message-types/messages.ts +0 -1
  70. package/src/daemon/message-types/notifications.ts +12 -0
  71. package/src/daemon/message-types/settings.ts +12 -0
  72. package/src/daemon/server.ts +21 -24
  73. package/src/daemon/transport-hints.ts +33 -0
  74. package/src/index.ts +1 -1
  75. package/src/memory/conversation-crud.ts +15 -10
  76. package/src/memory/conversation-directories.ts +39 -0
  77. package/src/memory/conversation-group-migration.ts +65 -5
  78. package/src/memory/embedding-local.ts +1 -1
  79. package/src/memory/graph/capability-seed.ts +3 -5
  80. package/src/memory/group-crud.ts +25 -9
  81. package/src/messaging/provider.ts +1 -1
  82. package/src/notifications/broadcaster.ts +6 -0
  83. package/src/notifications/conversation-pairing.ts +12 -4
  84. package/src/notifications/emit-signal.ts +14 -0
  85. package/src/notifications/signal.ts +11 -0
  86. package/src/oauth/platform-connection.test.ts +2 -2
  87. package/src/oauth/seed-providers.ts +1 -0
  88. package/src/permissions/checker.ts +3 -3
  89. package/src/permissions/defaults.ts +7 -8
  90. package/src/permissions/prompter.ts +0 -2
  91. package/src/platform/client.ts +1 -1
  92. package/src/prompts/templates/BOOTSTRAP.md +14 -5
  93. package/src/prompts/templates/SOUL.md +11 -11
  94. package/src/runtime/assistant-event-hub.ts +22 -0
  95. package/src/runtime/auth/token-service.ts +8 -0
  96. package/src/runtime/routes/conversation-analysis-routes.ts +18 -6
  97. package/src/runtime/routes/conversation-routes.ts +9 -3
  98. package/src/runtime/routes/group-routes.ts +22 -8
  99. package/src/runtime/routes/log-export/AGENTS.md +104 -0
  100. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
  101. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
  102. package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
  103. package/src/runtime/routes/log-export-routes.ts +18 -3
  104. package/src/skills/inline-command-runner.ts +12 -14
  105. package/src/tools/permission-checker.ts +0 -18
  106. package/src/tools/secret-detection-handler.ts +0 -1
  107. package/src/tools/skills/sandbox-runner.ts +3 -6
  108. package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
  109. package/src/tools/terminal/sandbox.ts +4 -1
  110. package/src/tools/terminal/shell.ts +3 -5
  111. package/src/tools/types.ts +0 -3
  112. package/src/watcher/provider-types.ts +1 -1
  113. package/src/workspace/migrations/029-seed-pkb.ts +1 -0
  114. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
  115. package/src/workspace/migrations/registry.ts +2 -0
@@ -45,7 +45,10 @@ mock.module("../security/secure-keys.js", () => ({
45
45
 
46
46
  // Mock global fetch
47
47
  const _originalFetch = globalThis.fetch;
48
- const mockFetch = async (input: string | URL | Request, init?: RequestInit) => {
48
+ const mockFetch = async (
49
+ input: string | URL | Request,
50
+ _init?: RequestInit,
51
+ ) => {
49
52
  const url =
50
53
  typeof input === "string"
51
54
  ? input
@@ -57,14 +60,6 @@ const mockFetch = async (input: string | URL | Request, init?: RequestInit) => {
57
60
  return new Response("Not Found", { status: 404 });
58
61
  }
59
62
 
60
- // Verify the Authorization header never leaks secrets into the request path
61
- const authHeader = init?.headers
62
- ? (init.headers as Record<string, string>)["Authorization"]
63
- : undefined;
64
- if (authHeader && !authHeader.startsWith("Api-Key ")) {
65
- throw new Error("Unexpected auth header format");
66
- }
67
-
68
63
  return new Response(JSON.stringify(entry.body), {
69
64
  status: entry.status,
70
65
  headers: { "Content-Type": "application/json" },
@@ -266,17 +261,17 @@ describe("fetchManagedCatalog", () => {
266
261
  expect(descriptor.handle).toBe("platform_oauth:conn_minimal");
267
262
  });
268
263
 
269
- test("error messages never contain API key values", async () => {
264
+ test("error messages never contain sensitive details", async () => {
270
265
  mockPlatformBaseUrl = "https://platform.example.com";
271
266
  mockAssistantApiKey = "sk-super-secret-key-12345";
272
267
  mockPlatformAssistantId = "ast-uuid-1234";
273
268
 
274
- // Simulate a network error
269
+ // Simulate a network error whose message contains sensitive data
275
270
  const savedFetch = globalThis.fetch;
276
271
  const errorFetch: typeof fetch = Object.assign(
277
272
  async () => {
278
273
  throw new Error(
279
- "Connect failed to https://platform.example.com/v1/assistants/ast-uuid-1234/oauth/managed/catalog/ with Api-Key sk-super-secret-key-12345",
274
+ "Connect failed to https://platform.example.com/v1/assistants/ast-uuid-1234/oauth/managed/catalog/ with Bearer sk-super-secret-key-12345",
280
275
  );
281
276
  },
282
277
  { preconnect: savedFetch.preconnect },
@@ -287,9 +282,12 @@ describe("fetchManagedCatalog", () => {
287
282
  const result = await fetchManagedCatalog();
288
283
  expect(result.ok).toBe(false);
289
284
  expect(result.error).toBeDefined();
290
- // Ensure the raw API key is not in the error message
285
+ // Raw error message (with URL, API key, etc.) must not leak
291
286
  expect(result.error).not.toContain("sk-super-secret-key-12345");
292
- expect(result.error).toContain("[REDACTED]");
287
+ expect(result.error).not.toContain("platform.example.com");
288
+ expect(result.error).not.toContain("Connect failed");
289
+ // Should only contain the error class name
290
+ expect(result.error).toContain("Error");
293
291
  } finally {
294
292
  globalThis.fetch = savedFetch;
295
293
  }
@@ -14,7 +14,7 @@ const CANONICAL_TABS = [
14
14
  "Sounds",
15
15
  "Permissions & Privacy",
16
16
  "Billing",
17
- "Archived Conversations",
17
+ "Archive",
18
18
  "Schedules",
19
19
  "Developer",
20
20
  ];
@@ -85,4 +85,17 @@ describe("navigate-settings-tab", () => {
85
85
  expect(result.isError).toBe(false);
86
86
  expect(result.content).toContain("Developer");
87
87
  });
88
+
89
+ test("normalizes legacy 'Archived Conversations' alias to 'Archive'", async () => {
90
+ const messages: unknown[] = [];
91
+ const result = await run(
92
+ { tab: "Archived Conversations" },
93
+ makeContext((msg) => messages.push(msg)),
94
+ );
95
+
96
+ expect(result.isError).toBe(false);
97
+ expect(result.content).toContain("Archive");
98
+ expect(messages).toHaveLength(1);
99
+ expect(messages[0]).toEqual({ type: "navigate_settings", tab: "Archive" });
100
+ });
88
101
  });
@@ -583,4 +583,69 @@ describe("notification broadcaster", () => {
583
583
  expect(vellumCall).toBeDefined();
584
584
  expect(vellumCall!.options?.bindingContext).toBeUndefined();
585
585
  });
586
+
587
+ // ── conversationMetadata propagation ──────────────────────────────
588
+
589
+ test("onConversationCreated includes groupId and source from conversationMetadata", async () => {
590
+ const vellumAdapter = new MockAdapter("vellum");
591
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
592
+ const createdCalls: ConversationCreatedInfo[] = [];
593
+ broadcaster.setOnConversationCreated((info) => createdCalls.push(info));
594
+
595
+ const signal = makeSignal({
596
+ sourceEventName: "schedule.complete",
597
+ conversationMetadata: {
598
+ groupId: "system:scheduled",
599
+ source: "schedule",
600
+ scheduleJobId: "job-abc-123",
601
+ },
602
+ });
603
+ const decision = makeDecision();
604
+
605
+ await broadcaster.broadcastDecision(signal, decision);
606
+
607
+ expect(createdCalls).toHaveLength(1);
608
+ expect(createdCalls[0].groupId).toBe("system:scheduled");
609
+ expect(createdCalls[0].source).toBe("schedule");
610
+ expect(createdCalls[0].sourceEventName).toBe("schedule.complete");
611
+ });
612
+
613
+ test("onConversationCreated omits groupId and source when conversationMetadata is absent", async () => {
614
+ const vellumAdapter = new MockAdapter("vellum");
615
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
616
+ const createdCalls: ConversationCreatedInfo[] = [];
617
+ broadcaster.setOnConversationCreated((info) => createdCalls.push(info));
618
+
619
+ const signal = makeSignal(); // no conversationMetadata
620
+ const decision = makeDecision();
621
+
622
+ await broadcaster.broadcastDecision(signal, decision);
623
+
624
+ expect(createdCalls).toHaveLength(1);
625
+ expect(createdCalls[0].groupId).toBeUndefined();
626
+ expect(createdCalls[0].source).toBeUndefined();
627
+ });
628
+
629
+ test("per-dispatch callback receives conversationMetadata fields", async () => {
630
+ const vellumAdapter = new MockAdapter("vellum");
631
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
632
+ const dispatchCalls: ConversationCreatedInfo[] = [];
633
+
634
+ const signal = makeSignal({
635
+ sourceEventName: "schedule.complete",
636
+ conversationMetadata: {
637
+ groupId: "system:scheduled",
638
+ source: "schedule",
639
+ },
640
+ });
641
+ const decision = makeDecision();
642
+
643
+ await broadcaster.broadcastDecision(signal, decision, {
644
+ onConversationCreated: (info) => dispatchCalls.push(info),
645
+ });
646
+
647
+ expect(dispatchCalls).toHaveLength(1);
648
+ expect(dispatchCalls[0].groupId).toBe("system:scheduled");
649
+ expect(dispatchCalls[0].source).toBe("schedule");
650
+ });
586
651
  });
@@ -104,11 +104,12 @@ describe("onboarding template contracts", () => {
104
104
  expect(bootstrap).toContain("Check my email");
105
105
  });
106
106
 
107
- test("includes daily briefing and channel suggestions in getting set up", () => {
107
+ test("keeps momentum by chaining off the first task", () => {
108
108
  const lower = bootstrap.toLowerCase();
109
- expect(lower).toContain("daily briefing");
110
- expect(lower).toContain("slack");
111
- expect(lower).toContain("telegram");
109
+ expect(lower).toContain("keep the momentum");
110
+ expect(lower).toContain("don't pivot to setup");
111
+ expect(lower).toContain("chain off the task");
112
+ expect(lower).toContain("while we're at it");
112
113
  });
113
114
  });
114
115
 
@@ -0,0 +1,96 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+
6
+ import { readAutoinjectList } from "../daemon/conversation-runtime-assembly.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Setup / Teardown
10
+ // ---------------------------------------------------------------------------
11
+
12
+ let pkbDir: string;
13
+ const dirs: string[] = [];
14
+
15
+ beforeEach(() => {
16
+ pkbDir = join(
17
+ tmpdir(),
18
+ `vellum-pkb-autoinject-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
19
+ );
20
+ mkdirSync(pkbDir, { recursive: true });
21
+ dirs.push(pkbDir);
22
+ });
23
+
24
+ afterEach(() => {
25
+ for (const dir of dirs.splice(0)) {
26
+ rmSync(dir, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Tests
32
+ // ---------------------------------------------------------------------------
33
+
34
+ describe("readAutoinjectList", () => {
35
+ test("returns null when _autoinject.md does not exist", () => {
36
+ expect(readAutoinjectList(pkbDir)).toBeNull();
37
+ });
38
+
39
+ test("returns empty array when _autoinject.md is empty", () => {
40
+ writeFileSync(join(pkbDir, "_autoinject.md"), "", "utf-8");
41
+ expect(readAutoinjectList(pkbDir)).toEqual([]);
42
+ });
43
+
44
+ test("returns empty array when _autoinject.md contains only comments", () => {
45
+ writeFileSync(
46
+ join(pkbDir, "_autoinject.md"),
47
+ "_ This is a comment\n_ Another comment\n",
48
+ "utf-8",
49
+ );
50
+ expect(readAutoinjectList(pkbDir)).toEqual([]);
51
+ });
52
+
53
+ test("parses standard default content", () => {
54
+ writeFileSync(
55
+ join(pkbDir, "_autoinject.md"),
56
+ "_ comment\n\nINDEX.md\nessentials.md\nthreads.md\nbuffer.md\n",
57
+ "utf-8",
58
+ );
59
+ expect(readAutoinjectList(pkbDir)).toEqual([
60
+ "INDEX.md",
61
+ "essentials.md",
62
+ "threads.md",
63
+ "buffer.md",
64
+ ]);
65
+ });
66
+
67
+ test("parses custom entries", () => {
68
+ writeFileSync(
69
+ join(pkbDir, "_autoinject.md"),
70
+ "INDEX.md\ncustom-topic.md\n",
71
+ "utf-8",
72
+ );
73
+ expect(readAutoinjectList(pkbDir)).toEqual([
74
+ "INDEX.md",
75
+ "custom-topic.md",
76
+ ]);
77
+ });
78
+
79
+ test("strips blank lines and whitespace", () => {
80
+ writeFileSync(
81
+ join(pkbDir, "_autoinject.md"),
82
+ "INDEX.md\n\n essentials.md \n\n",
83
+ "utf-8",
84
+ );
85
+ expect(readAutoinjectList(pkbDir)).toEqual(["INDEX.md", "essentials.md"]);
86
+ });
87
+
88
+ test("strips comment lines mixed with filenames", () => {
89
+ writeFileSync(
90
+ join(pkbDir, "_autoinject.md"),
91
+ "_ Always loaded files\nINDEX.md\n_ Core facts\nessentials.md\n",
92
+ "utf-8",
93
+ );
94
+ expect(readAutoinjectList(pkbDir)).toEqual(["INDEX.md", "essentials.md"]);
95
+ });
96
+ });
@@ -416,7 +416,6 @@ describe("requireFreshApproval: persistent decisions disabled", () => {
416
416
  _allowlistOptions: unknown[],
417
417
  _scopeOptions: unknown[],
418
418
  _previewDiff: unknown,
419
- _sandboxed: boolean | undefined,
420
419
  _conversationId: string,
421
420
  _executionTarget: string,
422
421
  persistentDecisionsAllowed: boolean,
@@ -467,7 +466,6 @@ describe("requireFreshApproval: persistent decisions disabled", () => {
467
466
  _allowlistOptions: unknown[],
468
467
  _scopeOptions: unknown[],
469
468
  _previewDiff: unknown,
470
- _sandboxed: boolean | undefined,
471
469
  _conversationId: string,
472
470
  _executionTarget: string,
473
471
  persistentDecisionsAllowed: boolean,
@@ -10,22 +10,6 @@ mock.module("node:child_process", () => ({
10
10
  execSync: execSyncMock,
11
11
  }));
12
12
 
13
- // Mock config loader — return a config with sandbox settings
14
- let mockSandboxConfig: {
15
- enabled: boolean;
16
- } = {
17
- enabled: true,
18
- };
19
-
20
- mock.module("../config/loader.js", () => ({
21
- getConfig: () => ({
22
- ui: {},
23
-
24
- sandbox: mockSandboxConfig,
25
- }),
26
- loadRawConfig: () => ({}),
27
- }));
28
-
29
13
  mock.module("../util/logger.js", () => ({
30
14
  getLogger: () => ({
31
15
  error: () => {},
@@ -49,9 +33,6 @@ function setPlatform(platform: string): void {
49
33
 
50
34
  beforeEach(() => {
51
35
  execSyncMock.mockReset();
52
- mockSandboxConfig = {
53
- enabled: true,
54
- };
55
36
  // Default: all commands succeed.
56
37
  execSyncMock.mockImplementation(() => undefined);
57
38
  });
@@ -62,26 +43,14 @@ afterEach(() => {
62
43
  });
63
44
 
64
45
  describe("runSandboxDiagnostics — config reporting", () => {
65
- test("reports sandbox enabled state", () => {
66
- const result = runSandboxDiagnostics();
67
- expect(result.config.enabled).toBe(true);
68
- });
69
-
70
- test("reports sandbox disabled state", () => {
71
- mockSandboxConfig.enabled = false;
46
+ test("reports sandbox disabled (sandbox removed)", () => {
72
47
  const result = runSandboxDiagnostics();
73
48
  expect(result.config.enabled).toBe(false);
74
49
  });
75
50
  });
76
51
 
77
52
  describe("runSandboxDiagnostics — active backend reason", () => {
78
- test("explains native backend selection", () => {
79
- const result = runSandboxDiagnostics();
80
- expect(result.activeBackendReason).toContain("Native backend");
81
- });
82
-
83
53
  test("explains when sandbox is disabled", () => {
84
- mockSandboxConfig.enabled = false;
85
54
  const result = runSandboxDiagnostics();
86
55
  expect(result.activeBackendReason).toContain("disabled");
87
56
  });
@@ -2,7 +2,7 @@ import * as realChildProcess from "node:child_process";
2
2
  import * as realFs from "node:fs";
3
3
  import { beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
- import type { SandboxConfig } from "../config/schema.js";
5
+ import type { SandboxConfig } from "../tools/terminal/sandbox.js";
6
6
 
7
7
  let platform = "linux";
8
8
 
@@ -65,13 +65,13 @@ mock.module("../tools/network/script-proxy/index.js", () => ({
65
65
 
66
66
  // ── Imports (after mocks) ───────────────────────────────────────────────────
67
67
 
68
- import type { SandboxConfig } from "../config/schema.js";
69
68
  import { parse } from "../tools/terminal/parser.js";
70
69
  import {
71
70
  ALWAYS_INJECTED_ENV_VARS,
72
71
  buildSanitizedEnv,
73
72
  SAFE_ENV_VARS,
74
73
  } from "../tools/terminal/safe-env.js";
74
+ import type { SandboxConfig } from "../tools/terminal/sandbox.js";
75
75
  import { wrapCommand } from "../tools/terminal/sandbox.js";
76
76
  import { ToolError } from "../util/errors.js";
77
77
 
@@ -455,10 +455,7 @@ describe("buildSanitizedEnv", () => {
455
455
  test("result is a plain object with no prototype-inherited secrets", () => {
456
456
  const env = buildSanitizedEnv();
457
457
  const keys = Object.keys(env);
458
- const safeKeys: string[] = [
459
- ...SAFE_ENV_VARS,
460
- ...ALWAYS_INJECTED_ENV_VARS,
461
- ];
458
+ const safeKeys: string[] = [...SAFE_ENV_VARS, ...ALWAYS_INJECTED_ENV_VARS];
462
459
  for (const key of keys) {
463
460
  expect(safeKeys).toContain(key);
464
461
  }
@@ -25,11 +25,25 @@ process.env.VELLUM_WORKSPACE_DIR = testDir;
25
25
  process.env.VELLUM_PLATFORM_URL = "https://test-platform.vellum.ai";
26
26
  process.exitCode = 0;
27
27
 
28
+ // Prevent tests from routing credential writes through the real CES
29
+ // (Credential Execution Service). Without this, setSecureKeyAsync() in
30
+ // containerized environments writes to the live credential store.
31
+ const savedIsContainerized = process.env.IS_CONTAINERIZED;
32
+ const savedCesCredentialUrl = process.env.CES_CREDENTIAL_URL;
33
+ delete process.env.IS_CONTAINERIZED;
34
+ delete process.env.CES_CREDENTIAL_URL;
35
+
28
36
  afterAll(() => {
29
37
  resetDb();
30
38
  process.exitCode = 0;
31
39
  delete process.env.VELLUM_WORKSPACE_DIR;
32
40
  delete process.env.VELLUM_PLATFORM_URL;
41
+ if (savedIsContainerized !== undefined) {
42
+ process.env.IS_CONTAINERIZED = savedIsContainerized;
43
+ }
44
+ if (savedCesCredentialUrl !== undefined) {
45
+ process.env.CES_CREDENTIAL_URL = savedCesCredentialUrl;
46
+ }
33
47
  try {
34
48
  rmSync(testDir, { recursive: true, force: true });
35
49
  } catch {
@@ -37,7 +37,6 @@ describe("createToolDomainEventPublisher", () => {
37
37
  reason: "needs approval",
38
38
  allowlistOptions: [],
39
39
  scopeOptions: [],
40
- sandboxed: true,
41
40
  });
42
41
 
43
42
  await publish({
@@ -41,7 +41,6 @@ let checkerDecision: "allow" | "prompt" | "deny" = "allow";
41
41
  let checkerReason = "allowed";
42
42
  let checkerRisk = "low";
43
43
  let promptDecision: "allow" | "always_allow" | "deny" | "always_deny" = "allow";
44
- let sandboxed = false;
45
44
  let fakeToolResult: ToolExecutionResult = { content: "ok", isError: false };
46
45
  let toolThrow: Error | null = null;
47
46
 
@@ -151,7 +150,7 @@ mock.module("../tools/shared/filesystem/path-policy.js", () => ({
151
150
  }));
152
151
 
153
152
  mock.module("../tools/terminal/sandbox.js", () => ({
154
- wrapCommand: () => ({ command: "", sandboxed }),
153
+ wrapCommand: () => ({ command: "", sandboxed: false }),
155
154
  }));
156
155
 
157
156
  import { PermissionPrompter } from "../permissions/prompter.js";
@@ -193,7 +192,6 @@ describe("ToolExecutor lifecycle events", () => {
193
192
  checkerReason = "allowed";
194
193
  checkerRisk = "low";
195
194
  promptDecision = "allow";
196
- sandboxed = false;
197
195
  fakeToolResult = { content: "ok", isError: false };
198
196
  toolThrow = null;
199
197
  });
@@ -231,7 +229,6 @@ describe("ToolExecutor lifecycle events", () => {
231
229
  checkerReason = "medium risk: requires approval";
232
230
  checkerRisk = "medium";
233
231
  promptDecision = "deny";
234
- sandboxed = true;
235
232
 
236
233
  const events: ToolLifecycleEvent[] = [];
237
234
  const executor = new ToolExecutor(makePrompter());
@@ -256,7 +253,6 @@ describe("ToolExecutor lifecycle events", () => {
256
253
  expect(promptEvent.executionTarget).toBe("sandbox");
257
254
  expect(promptEvent.riskLevel).toBe("medium");
258
255
  expect(promptEvent.reason).toBe("medium risk: requires approval");
259
- expect(promptEvent.sandboxed).toBe(true);
260
256
  expect(promptEvent.allowlistOptions).toEqual([
261
257
  { label: "exact", description: "exact", pattern: "exact" },
262
258
  ]);
@@ -276,7 +272,6 @@ describe("ToolExecutor lifecycle events", () => {
276
272
  checkerDecision = "prompt";
277
273
  checkerReason = "guardrail prompt";
278
274
  checkerRisk = "high";
279
- sandboxed = true;
280
275
 
281
276
  const events: ToolLifecycleEvent[] = [];
282
277
  const executor = new ToolExecutor(
@@ -580,7 +575,6 @@ describe("ToolExecutor lifecycle events", () => {
580
575
  checkerReason = "Matched trust rule";
581
576
  checkerRisk = "low";
582
577
  promptDecision = "allow";
583
- sandboxed = true;
584
578
 
585
579
  const events: ToolLifecycleEvent[] = [];
586
580
  const executor = new ToolExecutor(makePrompter());
@@ -602,7 +596,6 @@ describe("ToolExecutor lifecycle events", () => {
602
596
  expect(promptEvent.reason).toBe(
603
597
  "Private conversation: side-effect tools require explicit approval",
604
598
  );
605
- expect(promptEvent.sandboxed).toBe(true);
606
599
  });
607
600
 
608
601
  test("no permission_prompt event for read-only tool even with forcePromptSideEffects", async () => {
@@ -1949,7 +1949,6 @@ describe("ToolExecutor persistentDecisionsAllowed contract", () => {
1949
1949
  _allowlistOptions: AllowlistOption[],
1950
1950
  _scopeOptions: ScopeOption[],
1951
1951
  _diff: unknown,
1952
- _sandboxed: unknown,
1953
1952
  _conversationId: unknown,
1954
1953
  _executionTarget: unknown,
1955
1954
  persistentDecisionsAllowed: boolean | undefined,
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type {
4
+ MacosTransportMetadata,
5
+ NonMacosTransportMetadata,
6
+ } from "../daemon/message-types/conversations.js";
7
+ import { buildTransportHints } from "../daemon/transport-hints.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // buildTransportHints
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe("buildTransportHints", () => {
14
+ test("produces correct hints for macOS transport", () => {
15
+ const transport: MacosTransportMetadata = {
16
+ channelId: "vellum",
17
+ interfaceId: "macos",
18
+ hostHomeDir: "/Users/alice",
19
+ hostUsername: "alice",
20
+ };
21
+
22
+ const hints = buildTransportHints(transport);
23
+
24
+ expect(hints).toContain("User is messaging from interface: macos");
25
+ expect(hints).toContain("Host home directory: /Users/alice");
26
+ expect(hints).toContain("Host username: alice");
27
+ expect(hints).toHaveLength(3);
28
+ });
29
+
30
+ test("produces correct hints for non-macOS transport", () => {
31
+ const transport: NonMacosTransportMetadata = {
32
+ channelId: "vellum",
33
+ interfaceId: "ios",
34
+ };
35
+
36
+ const hints = buildTransportHints(transport);
37
+
38
+ expect(hints).toContain("User is messaging from interface: ios");
39
+ expect(hints).toHaveLength(1);
40
+ // Should not include host environment hints
41
+ expect(hints.some((h) => h.includes("Host home directory"))).toBe(false);
42
+ expect(hints.some((h) => h.includes("Host username"))).toBe(false);
43
+ });
44
+
45
+ test("includes client-provided hints", () => {
46
+ const transport: MacosTransportMetadata = {
47
+ channelId: "vellum",
48
+ interfaceId: "macos",
49
+ hostHomeDir: "/Users/bob",
50
+ hostUsername: "bob",
51
+ hints: ["custom hint"],
52
+ };
53
+
54
+ const hints = buildTransportHints(transport);
55
+
56
+ expect(hints).toContain("User is messaging from interface: macos");
57
+ expect(hints).toContain("Host home directory: /Users/bob");
58
+ expect(hints).toContain("Host username: bob");
59
+ expect(hints).toContain("custom hint");
60
+ expect(hints).toHaveLength(4);
61
+ });
62
+
63
+ test("handles missing optional fields on macOS transport", () => {
64
+ const transport: MacosTransportMetadata = {
65
+ channelId: "vellum",
66
+ interfaceId: "macos",
67
+ };
68
+
69
+ const hints = buildTransportHints(transport);
70
+
71
+ expect(hints).toContain("User is messaging from interface: macos");
72
+ // Without hostHomeDir and hostUsername, only the interface hint is present
73
+ expect(hints).toHaveLength(1);
74
+ expect(hints.some((h) => h.includes("Host home directory"))).toBe(false);
75
+ expect(hints.some((h) => h.includes("Host username"))).toBe(false);
76
+ });
77
+ });
@@ -909,8 +909,8 @@ describe("Trust Store", () => {
909
909
  expect(match!.id).toBe("default:allow-bash-rm-bootstrap");
910
910
  expect(match!.decision).toBe("allow");
911
911
  expect(match!.allowHighRisk).toBe(true);
912
- // Outside workspace, the bootstrap rule doesn't match — with sandbox
913
- // disabled (the default), there is no catch-all bash allow rule either.
912
+ // Outside workspace, the bootstrap rule doesn't match — without
913
+ // IS_CONTAINERIZED there is no catch-all bash allow rule either.
914
914
  const other = findHighestPriorityRule(
915
915
  "bash",
916
916
  ["rm BOOTSTRAP.md"],
@@ -930,8 +930,8 @@ describe("Trust Store", () => {
930
930
  expect(match!.id).toBe("default:allow-bash-rm-updates");
931
931
  expect(match!.decision).toBe("allow");
932
932
  expect(match!.allowHighRisk).toBe(true);
933
- // Outside workspace, should NOT match the updates rule — with sandbox
934
- // disabled (the default), there is no catch-all bash allow rule either.
933
+ // Outside workspace, should NOT match the updates rule — without
934
+ // IS_CONTAINERIZED there is no catch-all bash allow rule either.
935
935
  const other = findHighestPriorityRule(
936
936
  "bash",
937
937
  ["rm UPDATES.md"],