@vellumai/assistant 0.5.5 → 0.5.6

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 (102) hide show
  1. package/Dockerfile +3 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-token-service.test.ts +113 -0
  4. package/src/__tests__/config-schema.test.ts +2 -2
  5. package/src/__tests__/context-window-manager.test.ts +78 -0
  6. package/src/__tests__/conversation-title-service.test.ts +30 -1
  7. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  8. package/src/__tests__/memory-regressions.test.ts +8 -30
  9. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  10. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  11. package/src/__tests__/tool-executor.test.ts +4 -0
  12. package/src/cli/commands/conversations.ts +0 -18
  13. package/src/config/env.ts +8 -2
  14. package/src/config/feature-flag-registry.json +0 -8
  15. package/src/config/schema.ts +0 -12
  16. package/src/config/schemas/memory.ts +0 -4
  17. package/src/config/schemas/platform.ts +1 -1
  18. package/src/config/schemas/security.ts +4 -0
  19. package/src/context/window-manager.ts +53 -2
  20. package/src/daemon/config-watcher.ts +1 -4
  21. package/src/daemon/conversation-agent-loop.ts +0 -60
  22. package/src/daemon/conversation-memory.ts +0 -117
  23. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  24. package/src/daemon/handlers/conversations.ts +0 -11
  25. package/src/daemon/lifecycle.ts +3 -46
  26. package/src/followups/followup-store.ts +5 -2
  27. package/src/memory/conversation-crud.ts +0 -236
  28. package/src/memory/conversation-title-service.ts +26 -10
  29. package/src/memory/db-init.ts +5 -13
  30. package/src/memory/indexer.ts +15 -106
  31. package/src/memory/job-handlers/embedding.ts +0 -79
  32. package/src/memory/job-utils.ts +1 -1
  33. package/src/memory/jobs-store.ts +0 -8
  34. package/src/memory/jobs-worker.ts +0 -20
  35. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  36. package/src/memory/migrations/index.ts +1 -3
  37. package/src/memory/qdrant-client.ts +4 -6
  38. package/src/memory/schema/conversations.ts +0 -3
  39. package/src/memory/schema/index.ts +0 -2
  40. package/src/messaging/draft-store.ts +2 -2
  41. package/src/permissions/defaults.ts +3 -3
  42. package/src/permissions/trust-client.ts +2 -13
  43. package/src/permissions/trust-store.ts +8 -3
  44. package/src/runtime/auth/route-policy.ts +14 -0
  45. package/src/runtime/auth/token-service.ts +133 -0
  46. package/src/runtime/http-server.ts +2 -0
  47. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  48. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  49. package/src/runtime/routes/conversation-routes.ts +2 -1
  50. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  51. package/src/runtime/routes/memory-item-routes.ts +124 -2
  52. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  53. package/src/schedule/schedule-store.ts +0 -21
  54. package/src/skills/inline-command-render.ts +5 -1
  55. package/src/skills/inline-command-runner.ts +30 -2
  56. package/src/tools/memory/handlers.ts +1 -129
  57. package/src/tools/permission-checker.ts +18 -0
  58. package/src/tools/skills/load.ts +9 -2
  59. package/src/util/platform.ts +5 -5
  60. package/src/util/xml.ts +8 -0
  61. package/src/workspace/heartbeat-service.ts +5 -24
  62. package/src/__tests__/archive-recall.test.ts +0 -560
  63. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  64. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  65. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  66. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  67. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  68. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  69. package/src/__tests__/memory-brief-time.test.ts +0 -285
  70. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  71. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  72. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  73. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  74. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  75. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  76. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  77. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  78. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  79. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  80. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  81. package/src/__tests__/memory-reducer.test.ts +0 -704
  82. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  83. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  84. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  85. package/src/config/schemas/memory-simplified.ts +0 -101
  86. package/src/memory/archive-recall.ts +0 -516
  87. package/src/memory/archive-store.ts +0 -400
  88. package/src/memory/brief-formatting.ts +0 -33
  89. package/src/memory/brief-open-loops.ts +0 -266
  90. package/src/memory/brief-time.ts +0 -162
  91. package/src/memory/brief.ts +0 -75
  92. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  93. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  94. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  95. package/src/memory/migrations/186-memory-archive.ts +0 -109
  96. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  97. package/src/memory/reducer-scheduler.ts +0 -242
  98. package/src/memory/reducer-store.ts +0 -271
  99. package/src/memory/reducer-types.ts +0 -106
  100. package/src/memory/reducer.ts +0 -467
  101. package/src/memory/schema/memory-archive.ts +0 -121
  102. package/src/memory/schema/memory-brief.ts +0 -55
package/Dockerfile CHANGED
@@ -25,6 +25,9 @@ COPY packages/egress-proxy ./packages/egress-proxy
25
25
  COPY assistant/package.json assistant/bun.lock ./assistant/
26
26
  RUN cd /app/assistant && bun install --frozen-lockfile
27
27
 
28
+ # Copy meta files needed by assistant (provider-env-vars.json)
29
+ COPY meta/provider-env-vars.json ./meta/provider-env-vars.json
30
+
28
31
  # Copy source
29
32
  COPY assistant ./assistant
30
33
 
@@ -47,14 +50,10 @@ RUN apt-get update && apt-get install -y \
47
50
  g++ \
48
51
  git \
49
52
  sudo \
50
- bubblewrap \
51
53
  htop \
52
54
  procps \
53
55
  && rm -rf /var/lib/apt/lists/*
54
56
 
55
- ## Alias bwrap to bubblewrap
56
- RUN ln -sf /usr/bin/bwrap /usr/bin/bubblewrap
57
-
58
57
  # Copy bun binary from builder instead of re-installing
59
58
  COPY --from=builder /root/.bun/bin/bun /usr/local/bin/bun
60
59
  RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -57,6 +57,8 @@ import {
57
57
  } from "../runtime/actor-token-store.js";
58
58
  import { resetExternalAssistantIdCache } from "../runtime/auth/external-assistant-id.js";
59
59
  import {
60
+ BootstrapAlreadyCompleted,
61
+ fetchSigningKeyFromGateway,
60
62
  hashToken,
61
63
  initAuthSigningKey,
62
64
  } from "../runtime/auth/token-service.js";
@@ -729,3 +731,114 @@ describe("bootstrap private-network guard", () => {
729
731
  expect(res.status).toBe(200);
730
732
  });
731
733
  });
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // fetchSigningKeyFromGateway
737
+ // ---------------------------------------------------------------------------
738
+
739
+ describe("fetchSigningKeyFromGateway", () => {
740
+ const VALID_HEX_KEY = "a".repeat(64); // 64 hex chars = 32 bytes
741
+ const originalEnv = process.env.GATEWAY_INTERNAL_URL;
742
+ const originalFetch = globalThis.fetch;
743
+
744
+ beforeEach(() => {
745
+ process.env.GATEWAY_INTERNAL_URL = "http://gateway:7822";
746
+ });
747
+
748
+ afterAll(() => {
749
+ if (originalEnv !== undefined) {
750
+ process.env.GATEWAY_INTERNAL_URL = originalEnv;
751
+ } else {
752
+ delete process.env.GATEWAY_INTERNAL_URL;
753
+ }
754
+ globalThis.fetch = originalFetch;
755
+ });
756
+
757
+ test("returns 32-byte buffer on successful 200 response", async () => {
758
+ globalThis.fetch = (async () =>
759
+ new Response(JSON.stringify({ key: VALID_HEX_KEY }), {
760
+ status: 200,
761
+ headers: { "Content-Type": "application/json" },
762
+ })) as unknown as typeof fetch;
763
+
764
+ const key = await fetchSigningKeyFromGateway();
765
+ expect(key).toBeInstanceOf(Buffer);
766
+ expect(key.length).toBe(32);
767
+ expect(key.toString("hex")).toBe(VALID_HEX_KEY);
768
+ });
769
+
770
+ test("throws BootstrapAlreadyCompleted on 403 response", async () => {
771
+ globalThis.fetch = (async () =>
772
+ new Response("Forbidden", { status: 403 })) as unknown as typeof fetch;
773
+
774
+ await expect(fetchSigningKeyFromGateway()).rejects.toBeInstanceOf(
775
+ BootstrapAlreadyCompleted,
776
+ );
777
+ });
778
+
779
+ test("throws timeout error after max retry attempts on persistent failure", async () => {
780
+ // Mock Bun.sleep to avoid waiting 30s in tests
781
+ const origSleep = Bun.sleep;
782
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
783
+
784
+ let callCount = 0;
785
+ globalThis.fetch = (async () => {
786
+ callCount++;
787
+ throw new Error("ECONNREFUSED");
788
+ }) as unknown as typeof fetch;
789
+
790
+ try {
791
+ await expect(fetchSigningKeyFromGateway()).rejects.toThrow(
792
+ "timed out waiting for gateway",
793
+ );
794
+ expect(callCount).toBe(30);
795
+ } finally {
796
+ Bun.sleep = origSleep;
797
+ }
798
+ });
799
+
800
+ test("throws when GATEWAY_INTERNAL_URL is not set", async () => {
801
+ delete process.env.GATEWAY_INTERNAL_URL;
802
+
803
+ await expect(fetchSigningKeyFromGateway()).rejects.toThrow(
804
+ "GATEWAY_INTERNAL_URL not set",
805
+ );
806
+ });
807
+
808
+ test("rejects invalid key length", async () => {
809
+ globalThis.fetch = (async () =>
810
+ new Response(JSON.stringify({ key: "aabb" }), {
811
+ status: 200,
812
+ headers: { "Content-Type": "application/json" },
813
+ })) as unknown as typeof fetch;
814
+
815
+ await expect(fetchSigningKeyFromGateway()).rejects.toThrow(
816
+ "Invalid signing key length",
817
+ );
818
+ });
819
+
820
+ test("retries on non-200/non-403 status and eventually succeeds", async () => {
821
+ const origSleep = Bun.sleep;
822
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
823
+
824
+ let callCount = 0;
825
+ globalThis.fetch = (async () => {
826
+ callCount++;
827
+ if (callCount < 3) {
828
+ return new Response("Service Unavailable", { status: 503 });
829
+ }
830
+ return new Response(JSON.stringify({ key: VALID_HEX_KEY }), {
831
+ status: 200,
832
+ headers: { "Content-Type": "application/json" },
833
+ });
834
+ }) as unknown as typeof fetch;
835
+
836
+ try {
837
+ const key = await fetchSigningKeyFromGateway();
838
+ expect(key.length).toBe(32);
839
+ expect(callCount).toBe(3);
840
+ } finally {
841
+ Bun.sleep = origSleep;
842
+ }
843
+ });
844
+ });
@@ -437,7 +437,7 @@ describe("AssistantConfigSchema", () => {
437
437
 
438
438
  test("defaults permissions.mode to workspace", () => {
439
439
  const result = AssistantConfigSchema.parse({});
440
- expect(result.permissions).toEqual({ mode: "workspace" });
440
+ expect(result.permissions).toEqual({ mode: "workspace", dangerouslySkipPermissions: false });
441
441
  });
442
442
 
443
443
  test("accepts explicit permissions.mode strict", () => {
@@ -1139,7 +1139,7 @@ describe("loadConfig with schema validation", () => {
1139
1139
  test("defaults permissions.mode to workspace when not specified", () => {
1140
1140
  writeConfig({});
1141
1141
  const config = loadConfig();
1142
- expect(config.permissions).toEqual({ mode: "workspace" });
1142
+ expect(config.permissions).toEqual({ mode: "workspace", dangerouslySkipPermissions: false });
1143
1143
  });
1144
1144
 
1145
1145
  test("loads explicit permissions.mode strict", () => {
@@ -344,6 +344,84 @@ describe("ContextWindowManager", () => {
344
344
  expect(result.compactedPersistedMessages).toBe(4);
345
345
  });
346
346
 
347
+ test("adjusts keep boundary to preserve tool_use/tool_result pairs", async () => {
348
+ const provider = createProvider(() => ({
349
+ content: [{ type: "text", text: "## Goals\n- compacted summary" }],
350
+ model: "mock-model",
351
+ usage: { inputTokens: 75, outputTokens: 20 },
352
+ stopReason: "end_turn",
353
+ }));
354
+ // Configure budget so compaction keeps only the last user turn,
355
+ // which would normally split the tool pair because the last user
356
+ // turn start is a mixed message (tool_result + text) whose matching
357
+ // tool_use lives in the preceding assistant message.
358
+ const manager = new ContextWindowManager({
359
+ provider,
360
+ systemPrompt: "system prompt",
361
+ config: makeConfig({
362
+ maxInputTokens: 320,
363
+ targetBudgetRatio: 0.58,
364
+ }),
365
+ });
366
+ const long = "k".repeat(220);
367
+ const history: Message[] = [
368
+ message("user", `u1 ${long}`), // index 0: old user turn (long)
369
+ message("assistant", `a1 ${long}`), // index 1: assistant reply (long)
370
+ message("user", `u2 ${long}`), // index 2: second user turn (long)
371
+ {
372
+ // index 3: assistant with tool_use
373
+ role: "assistant",
374
+ content: [
375
+ {
376
+ type: "tool_use",
377
+ id: "t1",
378
+ name: "read_file",
379
+ input: { path: "/tmp/a" },
380
+ },
381
+ ],
382
+ },
383
+ {
384
+ // index 4: user with tool_result AND text (mixed = user turn start)
385
+ // Without adjustForToolPairs, the raw boundary would land here,
386
+ // orphaning the tool_result from its tool_use at index 3.
387
+ role: "user",
388
+ content: [
389
+ { type: "tool_result", tool_use_id: "t1", content: "file contents" },
390
+ { type: "text", text: "thanks, now continue" },
391
+ ],
392
+ },
393
+ ];
394
+
395
+ const result = await manager.maybeCompact(history);
396
+ expect(result.compacted).toBe(true);
397
+ // The kept messages must include the tool_use assistant message (index 3)
398
+ // and tool_result user message (index 4) as a pair, not split them.
399
+ // Verify no orphaned tool_result blocks exist in the kept messages.
400
+ const keptMessages = result.messages;
401
+ for (let i = 0; i < keptMessages.length; i++) {
402
+ const msg = keptMessages[i];
403
+ if (msg.role !== "user") continue;
404
+ for (const block of msg.content) {
405
+ if (block.type === "tool_result") {
406
+ // Every tool_result must have a matching tool_use in a preceding assistant message
407
+ const toolUseId = (block as { tool_use_id: string }).tool_use_id;
408
+ const hasMatchingToolUse = keptMessages
409
+ .slice(0, i)
410
+ .some(
411
+ (prev) =>
412
+ prev.role === "assistant" &&
413
+ prev.content.some(
414
+ (b) =>
415
+ b.type === "tool_use" &&
416
+ (b as { id: string }).id === toolUseId,
417
+ ),
418
+ );
419
+ expect(hasMatchingToolUse).toBe(true);
420
+ }
421
+ }
422
+ }
423
+ });
424
+
347
425
  test("counts mixed tool_result+text user messages as persisted", async () => {
348
426
  const provider = createProvider(() => ({
349
427
  content: [{ type: "text", text: "## Goals\n- mixed summary" }],
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- const mockRunBtwSidechain = mock(async () => ({
3
+ const mockRunBtwSidechain = mock(async (_params: Record<string, unknown>) => ({
4
4
  text: "Project kickoff",
5
5
  hadTextDeltas: true,
6
6
  response: {
@@ -93,6 +93,8 @@ describe("conversation-title-service", () => {
93
93
  expect(mockRunBtwSidechain).toHaveBeenCalledWith(
94
94
  expect.objectContaining({
95
95
  provider,
96
+ systemPrompt: expect.stringContaining("conversation titles"),
97
+ tools: [],
96
98
  maxTokens: 37,
97
99
  modelIntent: "latency-optimized",
98
100
  timeoutMs: 10_000,
@@ -123,6 +125,8 @@ describe("conversation-title-service", () => {
123
125
  expect(mockRunBtwSidechain).toHaveBeenCalledWith(
124
126
  expect.objectContaining({
125
127
  provider,
128
+ systemPrompt: expect.stringContaining("conversation titles"),
129
+ tools: [],
126
130
  maxTokens: 37,
127
131
  modelIntent: "latency-optimized",
128
132
  timeoutMs: 10_000,
@@ -134,4 +138,29 @@ describe("conversation-title-service", () => {
134
138
  1,
135
139
  );
136
140
  });
141
+
142
+ test("title prompt content does not contain generation instructions", async () => {
143
+ const provider = {
144
+ name: "test-provider",
145
+ sendMessage: mock(async () => {
146
+ throw new Error("provider.sendMessage should not be called directly");
147
+ }),
148
+ };
149
+
150
+ await generateAndPersistConversationTitle({
151
+ conversationId: "conv-1",
152
+ provider,
153
+ userMessage: "Help me plan the kickoff",
154
+ });
155
+
156
+ const call = mockRunBtwSidechain.mock.calls[0]![0] as {
157
+ content: string;
158
+ systemPrompt: string;
159
+ };
160
+ // Instructions should be in systemPrompt, not in content
161
+ expect(call.content).not.toContain("Generate a very short title");
162
+ expect(call.content).not.toContain("do NOT respond");
163
+ expect(call.systemPrompt).toContain("Do NOT respond");
164
+ expect(call.systemPrompt).toContain("Maximum 5 words");
165
+ });
137
166
  });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Integration tests for resolveSigningKey() covering the Docker bootstrap
3
+ * lifecycle: fresh fetch from gateway, daemon restart (load from disk),
4
+ * and local mode (file-based load/create).
5
+ */
6
+
7
+ import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Temp directory for signing key persistence
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), "docker-signing-key-test-")));
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Mock platform to redirect signing key file to our temp directory
20
+ // ---------------------------------------------------------------------------
21
+
22
+ mock.module("../util/platform.js", () => ({
23
+ getRootDir: () => testDir,
24
+ getDataDir: () => testDir,
25
+ getDbPath: () => join(testDir, "test.db"),
26
+ normalizeAssistantId: (id: string) => (id === "self" ? "self" : id),
27
+ readLockfile: () => null,
28
+ writeLockfile: () => {},
29
+ isMacOS: () => process.platform === "darwin",
30
+ isLinux: () => process.platform === "linux",
31
+ isWindows: () => process.platform === "win32",
32
+ getPidPath: () => join(testDir, "test.pid"),
33
+ getLogPath: () => join(testDir, "test.log"),
34
+ ensureDataDir: () => {},
35
+ }));
36
+
37
+ mock.module("../util/logger.js", () => ({
38
+ getLogger: () =>
39
+ new Proxy({} as Record<string, unknown>, {
40
+ get: () => () => {},
41
+ }),
42
+ }));
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Import the functions under test (after mocks are installed)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const {
49
+ resolveSigningKey,
50
+ loadOrCreateSigningKey: _loadOrCreateSigningKey,
51
+ BootstrapAlreadyCompleted: _BootstrapAlreadyCompleted,
52
+ } = await import("../runtime/auth/token-service.js");
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Test constants
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const VALID_32_BYTE_KEY = "ab".repeat(32); // 64 hex chars = 32 bytes
59
+ const SIGNING_KEY_PATH = join(testDir, "protected", "actor-token-signing-key");
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Environment & fetch state management
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const originalFetch = globalThis.fetch;
66
+ const savedEnv: Record<string, string | undefined> = {};
67
+
68
+ function saveEnv(...keys: string[]) {
69
+ for (const key of keys) {
70
+ savedEnv[key] = process.env[key];
71
+ }
72
+ }
73
+
74
+ function restoreEnv() {
75
+ for (const [key, val] of Object.entries(savedEnv)) {
76
+ if (val === undefined) {
77
+ delete process.env[key];
78
+ } else {
79
+ process.env[key] = val;
80
+ }
81
+ }
82
+ }
83
+
84
+ beforeEach(() => {
85
+ saveEnv("IS_CONTAINERIZED", "GATEWAY_INTERNAL_URL");
86
+ });
87
+
88
+ afterEach(() => {
89
+ globalThis.fetch = originalFetch;
90
+ restoreEnv();
91
+ });
92
+
93
+ afterAll(() => {
94
+ try {
95
+ rmSync(testDir, { recursive: true, force: true });
96
+ } catch {}
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Docker mode tests — resolveSigningKey() bootstrap lifecycle
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe("resolveSigningKey — Docker bootstrap lifecycle", () => {
104
+ test("fresh bootstrap: fetches key from gateway and persists to disk", async () => {
105
+ process.env.IS_CONTAINERIZED = "true";
106
+ process.env.GATEWAY_INTERNAL_URL = "http://localhost:19876";
107
+
108
+ // Mock fetch to return a known 32-byte key on first call.
109
+ globalThis.fetch = (async () =>
110
+ new Response(JSON.stringify({ key: VALID_32_BYTE_KEY }), {
111
+ status: 200,
112
+ headers: { "Content-Type": "application/json" },
113
+ })) as unknown as typeof fetch;
114
+
115
+ const key = await resolveSigningKey();
116
+
117
+ // Verify the returned key is a 32-byte buffer with the expected content.
118
+ expect(key).toBeInstanceOf(Buffer);
119
+ expect(key.length).toBe(32);
120
+ expect(key.toString("hex")).toBe(VALID_32_BYTE_KEY);
121
+
122
+ // Verify the key was persisted to disk.
123
+ const persisted = readFileSync(SIGNING_KEY_PATH);
124
+ expect(persisted.length).toBe(32);
125
+ expect(Buffer.from(persisted).equals(key)).toBe(true);
126
+ });
127
+
128
+ test("daemon restart: gateway returns 403, loads persisted key from disk", async () => {
129
+ process.env.IS_CONTAINERIZED = "true";
130
+ process.env.GATEWAY_INTERNAL_URL = "http://localhost:19876";
131
+
132
+ // The previous test persisted the key. Simulate a daemon restart where
133
+ // the gateway returns 403 (bootstrap already completed).
134
+ globalThis.fetch = (async () =>
135
+ new Response(JSON.stringify({ error: "Bootstrap already completed" }), {
136
+ status: 403,
137
+ })) as unknown as typeof fetch;
138
+
139
+ const key = await resolveSigningKey();
140
+
141
+ // Should have loaded the previously persisted key from disk.
142
+ expect(key).toBeInstanceOf(Buffer);
143
+ expect(key.length).toBe(32);
144
+ expect(key.toString("hex")).toBe(VALID_32_BYTE_KEY);
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Local mode tests — resolveSigningKey() file-based path
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe("resolveSigningKey — local mode", () => {
153
+ test("uses file-based loadOrCreateSigningKey without calling fetch", async () => {
154
+ // Ensure Docker env vars are unset.
155
+ delete process.env.IS_CONTAINERIZED;
156
+ delete process.env.GATEWAY_INTERNAL_URL;
157
+
158
+ let fetchCalled = false;
159
+ globalThis.fetch = (async () => {
160
+ fetchCalled = true;
161
+ return new Response("should not be called", { status: 500 });
162
+ }) as unknown as typeof fetch;
163
+
164
+ const key = await resolveSigningKey();
165
+
166
+ // Should return a valid 32-byte key (loaded from disk or newly created).
167
+ expect(key).toBeInstanceOf(Buffer);
168
+ expect(key.length).toBe(32);
169
+
170
+ // Crucially, fetch should NOT have been called.
171
+ expect(fetchCalled).toBe(false);
172
+ });
173
+
174
+ test("IS_CONTAINERIZED=false does not trigger gateway fetch", async () => {
175
+ process.env.IS_CONTAINERIZED = "false";
176
+ process.env.GATEWAY_INTERNAL_URL = "http://localhost:19876";
177
+
178
+ let fetchCalled = false;
179
+ globalThis.fetch = (async () => {
180
+ fetchCalled = true;
181
+ return new Response("should not be called", { status: 500 });
182
+ }) as unknown as typeof fetch;
183
+
184
+ const key = await resolveSigningKey();
185
+
186
+ expect(key).toBeInstanceOf(Buffer);
187
+ expect(key.length).toBe(32);
188
+ expect(fetchCalled).toBe(false);
189
+ });
190
+
191
+ test("IS_CONTAINERIZED=true without GATEWAY_INTERNAL_URL uses local path", async () => {
192
+ process.env.IS_CONTAINERIZED = "true";
193
+ delete process.env.GATEWAY_INTERNAL_URL;
194
+
195
+ let fetchCalled = false;
196
+ globalThis.fetch = (async () => {
197
+ fetchCalled = true;
198
+ return new Response("should not be called", { status: 500 });
199
+ }) as unknown as typeof fetch;
200
+
201
+ const key = await resolveSigningKey();
202
+
203
+ expect(key).toBeInstanceOf(Buffer);
204
+ expect(key.length).toBe(32);
205
+ expect(fetchCalled).toBe(false);
206
+ });
207
+ });
@@ -540,23 +540,13 @@ describe("Memory regressions", () => {
540
540
 
541
541
  test("memory_save sets verificationState to user_confirmed", async () => {
542
542
  const { handleMemorySave } = await import("../tools/memory/handlers.js");
543
- const legacyConfig = {
544
- ...DEFAULT_CONFIG,
545
- memory: {
546
- ...DEFAULT_CONFIG.memory,
547
- simplified: {
548
- ...DEFAULT_CONFIG.memory.simplified,
549
- enabled: false,
550
- },
551
- },
552
- };
553
543
 
554
544
  const result = await handleMemorySave(
555
545
  {
556
546
  statement: "User explicitly saved this preference",
557
547
  kind: "preference",
558
548
  },
559
- legacyConfig,
549
+ DEFAULT_CONFIG,
560
550
  "conv-verify-save",
561
551
  "msg-verify-save",
562
552
  );
@@ -573,23 +563,13 @@ describe("Memory regressions", () => {
573
563
 
574
564
  test("memory_save in different scopes creates separate items", async () => {
575
565
  const { handleMemorySave } = await import("../tools/memory/handlers.js");
576
- const legacyConfig = {
577
- ...DEFAULT_CONFIG,
578
- memory: {
579
- ...DEFAULT_CONFIG.memory,
580
- simplified: {
581
- ...DEFAULT_CONFIG.memory.simplified,
582
- enabled: false,
583
- },
584
- },
585
- };
586
566
 
587
567
  const sharedArgs = { statement: "I prefer dark mode", kind: "preference" };
588
568
 
589
569
  // Save in the default scope
590
570
  const r1 = await handleMemorySave(
591
571
  sharedArgs,
592
- legacyConfig,
572
+ DEFAULT_CONFIG,
593
573
  "conv-scope-1",
594
574
  "msg-scope-1",
595
575
  "default",
@@ -600,7 +580,7 @@ describe("Memory regressions", () => {
600
580
  // Save the identical statement in a private scope
601
581
  const r2 = await handleMemorySave(
602
582
  sharedArgs,
603
- legacyConfig,
583
+ DEFAULT_CONFIG,
604
584
  "conv-scope-2",
605
585
  "msg-scope-2",
606
586
  "private-abc",
@@ -624,7 +604,7 @@ describe("Memory regressions", () => {
624
604
  // Saving the same statement again in default scope should dedup (not create a third)
625
605
  const r3 = await handleMemorySave(
626
606
  sharedArgs,
627
- legacyConfig,
607
+ DEFAULT_CONFIG,
628
608
  "conv-scope-3",
629
609
  "msg-scope-3",
630
610
  "default",
@@ -3227,9 +3207,8 @@ describe("Memory regressions", () => {
3227
3207
  .filter((j) => JSON.parse(j.payload).messageId === "msg-untrusted-gate");
3228
3208
  expect(extractJobs.length).toBe(0);
3229
3209
 
3230
- // enqueuedJobs reflects legacy embed_segment + archive embed_chunk per
3231
- // segment, plus the summary job, with extract_items gated off.
3232
- const expectedJobs = result.indexedSegments * 2 + 1;
3210
+ // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0)
3211
+ const expectedJobs = result.indexedSegments + 1; // embed per segment + summary
3233
3212
  expect(result.enqueuedJobs).toBe(expectedJobs);
3234
3213
  });
3235
3214
 
@@ -3410,9 +3389,8 @@ describe("Memory regressions", () => {
3410
3389
  .filter((j) => JSON.parse(j.payload).messageId === "msg-unverified-gate");
3411
3390
  expect(extractJobs.length).toBe(0);
3412
3391
 
3413
- // enqueuedJobs reflects legacy embed_segment + archive embed_chunk per
3414
- // segment, plus the summary job, with extract_items gated off.
3415
- const expectedJobs = result.indexedSegments * 2 + 1;
3392
+ // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0)
3393
+ const expectedJobs = result.indexedSegments + 1; // embed per segment + summary
3416
3394
  expect(result.enqueuedJobs).toBe(expectedJobs);
3417
3395
  });
3418
3396
 
@@ -58,6 +58,10 @@ const mockConfig = {
58
58
  action: "warn" as const,
59
59
  entropyThreshold: 4.0,
60
60
  },
61
+ permissions: {
62
+ mode: "workspace" as const,
63
+ dangerouslySkipPermissions: false,
64
+ },
61
65
  };
62
66
 
63
67
  let fakeToolResult: ToolExecutionResult = { content: "ok", isError: false };
@@ -32,6 +32,10 @@ const mockConfig = {
32
32
  action: "warn" as const,
33
33
  entropyThreshold: 4.0,
34
34
  },
35
+ permissions: {
36
+ mode: "workspace" as const,
37
+ dangerouslySkipPermissions: false,
38
+ },
35
39
  };
36
40
 
37
41
  let checkerDecision: "allow" | "prompt" | "deny" = "allow";
@@ -50,6 +50,10 @@ const mockConfig = {
50
50
  action: "warn" as const,
51
51
  entropyThreshold: 4.0,
52
52
  },
53
+ permissions: {
54
+ mode: "workspace" as const,
55
+ dangerouslySkipPermissions: false,
56
+ },
53
57
  };
54
58
 
55
59
  let fakeToolResult: ToolExecutionResult = { content: "ok", isError: false };
@@ -375,24 +375,6 @@ Examples:
375
375
  targetId: summaryId,
376
376
  });
377
377
  }
378
- for (const obsId of result.deletedObservationIds) {
379
- enqueueMemoryJob("delete_qdrant_vectors", {
380
- targetType: "observation",
381
- targetId: obsId,
382
- });
383
- }
384
- for (const chunkId of result.deletedChunkIds) {
385
- enqueueMemoryJob("delete_qdrant_vectors", {
386
- targetType: "chunk",
387
- targetId: chunkId,
388
- });
389
- }
390
- for (const episodeId of result.deletedEpisodeIds) {
391
- enqueueMemoryJob("delete_qdrant_vectors", {
392
- targetType: "episode",
393
- targetId: episodeId,
394
- });
395
- }
396
378
 
397
379
  log.info(
398
380
  `Wiped conversation "${conversation.title ?? "Untitled"}". ` +