@vellumai/assistant 0.5.4 → 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 (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. 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,57 +50,44 @@ RUN apt-get update && apt-get install -y \
47
50
  g++ \
48
51
  git \
49
52
  sudo \
53
+ htop \
54
+ procps \
50
55
  && rm -rf /var/lib/apt/lists/*
51
56
 
52
57
  # Copy bun binary from builder instead of re-installing
53
58
  COPY --from=builder /root/.bun/bin/bun /usr/local/bin/bun
54
59
  RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
55
60
 
61
+ # Install assistant CLI launcher backed by the bundled assistant package
62
+ RUN printf '#!/usr/bin/env sh\nexec bun run /app/assistant/src/index.ts "$@"\n' > /usr/local/bin/assistant && \
63
+ chmod +x /usr/local/bin/assistant
64
+
56
65
  # Create non-root user that also has sudo access so it can like install stuff
57
66
  RUN groupadd --system --gid 1001 assistant && \
58
67
  useradd --system --uid 1001 --gid assistant --create-home --shell /bin/bash assistant && \
59
68
  echo "assistant ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
60
69
 
61
- # Set up data directory
62
- RUN mkdir -p /home/assistant/.vellum /data && \
63
- chown -R assistant:assistant /home/assistant/.vellum /data && \
64
- chmod a+rwx /data
70
+ # Set up assistant home directory for local state (device.json, etc.)
71
+ RUN mkdir -p /home/assistant/.vellum && \
72
+ chown -R assistant:assistant /home/assistant/.vellum
65
73
 
66
74
  # Update PATH for assistant user
67
- ENV PATH="/home/assistant/.bun/bin:/data/bin:${PATH}"
75
+ ENV PATH="/home/assistant/.bun/bin:${PATH}"
68
76
 
69
- # Configure package managers to use /data
70
- ENV BUN_INSTALL="/data/.bun"
77
+ # Configure package managers to use assistant home
78
+ ENV BUN_INSTALL="/home/assistant/.bun"
71
79
  ENV PATH="${BUN_INSTALL}/bin:${PATH}"
72
- ENV PYTHONUSERBASE="/data/.python"
80
+ ENV PYTHONUSERBASE="/home/assistant/.python"
73
81
  ENV PATH="${PYTHONUSERBASE}/bin:${PATH}"
74
82
 
75
- # Configure apt/dpkg to install future packages to /data
76
- RUN mkdir -p /data/dpkg/info /data/dpkg/updates /data/dpkg/triggers && \
77
- mkdir -p /data/usr/bin /data/usr/lib /data/usr/share && \
78
- chown -R assistant:assistant /data/dpkg /data/usr
79
-
80
- # Create dpkg configuration for using /data as install prefix
81
- RUN echo 'Dir::State "/data/dpkg";' > /etc/apt/apt.conf.d/99data-dir && \
82
- echo 'Dir::State::status "/data/dpkg/status";' >> /etc/apt/apt.conf.d/99data-dir && \
83
- echo 'Dir::Cache "/data/apt/cache";' >> /etc/apt/apt.conf.d/99data-dir && \
84
- echo 'DPkg::Options {"--instdir=/data/usr";"--admindir=/data/dpkg";"--force-not-root";"--force-bad-path";};' >> /etc/apt/apt.conf.d/99data-dir && \
85
- mkdir -p /data/apt/cache && \
86
- touch /data/dpkg/status && \
87
- chown -R assistant:assistant /data/apt /data/dpkg
88
-
89
- ENV PATH="/data/usr/bin:/data/usr/sbin:${PATH}"
90
- ENV LD_LIBRARY_PATH="/data/usr/lib:/data/usr/lib/x86_64-linux-gnu:/data/usr/lib/aarch64-linux-gnu"
91
-
92
83
  # Ensure the CES bootstrap socket volume is writable by the non-root CES user.
93
84
  RUN mkdir -p /run/ces-bootstrap && chmod 777 /run/ces-bootstrap
94
85
 
95
- USER root
86
+ USER assistant
96
87
 
97
88
  EXPOSE 3001
98
89
 
99
90
  ENV RUNTIME_HTTP_PORT=3001
100
- ENV BASE_DATA_DIR=/data
101
91
  ENV IS_CONTAINERIZED=true
102
92
 
103
93
  # Copy from builder
@@ -145,3 +145,4 @@ export * from "./handles.js";
145
145
  export * from "./grants.js";
146
146
  export * from "./rpc.js";
147
147
  export * from "./rendering.js";
148
+ export * from "./trust-rules.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Trust rule types shared between the assistant daemon and the gateway.
3
+ *
4
+ * These are extracted from `assistant/src/permissions/types.ts` and
5
+ * `assistant/src/permissions/trust-store.ts` so that both packages can
6
+ * reference a single canonical definition.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Trust decision
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** The possible decisions a trust rule can make. */
14
+ export type TrustDecision = "allow" | "deny" | "ask";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Trust rule
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface TrustRule {
21
+ id: string;
22
+ tool: string;
23
+ pattern: string;
24
+ scope: string;
25
+ decision: TrustDecision;
26
+ priority: number;
27
+ createdAt: number;
28
+ executionTarget?: string;
29
+ allowHighRisk?: boolean;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Trust file (on-disk shape)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** Shape of the `trust.json` file persisted to disk. */
37
+ export interface TrustFileData {
38
+ version: number;
39
+ rules: TrustRule[];
40
+ /** Set to true when the user explicitly accepts the starter approval bundle. */
41
+ starterBundleAccepted?: boolean;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.5.4",
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
  });
@@ -222,6 +222,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
222
222
  "messaging/providers/telegram-bot/adapter.ts", // Telegram bot token lookup for connectivity check
223
223
  "runtime/channel-readiness-service.ts", // channel readiness probes for Telegram connectivity
224
224
  "messaging/providers/whatsapp/adapter.ts", // WhatsApp credential lookup for connectivity check
225
+ "messaging/providers/slack/adapter.ts", // Slack bot token lookup for Socket Mode connectivity check
225
226
  "daemon/handlers/config-slack-channel.ts", // Slack channel config credential management
226
227
  "providers/managed-proxy/context.ts", // managed proxy API key lookup for provider initialization
227
228
  "mcp/mcp-oauth-provider.ts", // MCP OAuth token/client/discovery persistence
@@ -254,6 +255,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
254
255
  "config/bundled-skills/slack/tools/shared.ts", // Slack skill bot token lookup
255
256
  "daemon/conversation-process.ts", // masked provider key display
256
257
  "daemon/handlers/config-model.ts", // masked provider key display
258
+ "providers/speech-to-text/resolve.ts", // STT provider API key lookup
257
259
  ]);
258
260
 
259
261
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -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
+ });