@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.
- package/Dockerfile +3 -4
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +113 -0
- package/src/__tests__/config-schema.test.ts +2 -2
- package/src/__tests__/context-window-manager.test.ts +78 -0
- package/src/__tests__/conversation-title-service.test.ts +30 -1
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
- package/src/__tests__/memory-regressions.test.ts +8 -30
- package/src/__tests__/require-fresh-approval.test.ts +4 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +4 -0
- package/src/cli/commands/conversations.ts +0 -18
- package/src/config/env.ts +8 -2
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/schema.ts +0 -12
- package/src/config/schemas/memory.ts +0 -4
- package/src/config/schemas/platform.ts +1 -1
- package/src/config/schemas/security.ts +4 -0
- package/src/context/window-manager.ts +53 -2
- package/src/daemon/config-watcher.ts +1 -4
- package/src/daemon/conversation-agent-loop.ts +0 -60
- package/src/daemon/conversation-memory.ts +0 -117
- package/src/daemon/conversation-runtime-assembly.ts +0 -2
- package/src/daemon/handlers/conversations.ts +0 -11
- package/src/daemon/lifecycle.ts +3 -46
- package/src/followups/followup-store.ts +5 -2
- package/src/memory/conversation-crud.ts +0 -236
- package/src/memory/conversation-title-service.ts +26 -10
- package/src/memory/db-init.ts +5 -13
- package/src/memory/indexer.ts +15 -106
- package/src/memory/job-handlers/embedding.ts +0 -79
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +0 -8
- package/src/memory/jobs-worker.ts +0 -20
- package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
- package/src/memory/migrations/index.ts +1 -3
- package/src/memory/qdrant-client.ts +4 -6
- package/src/memory/schema/conversations.ts +0 -3
- package/src/memory/schema/index.ts +0 -2
- package/src/messaging/draft-store.ts +2 -2
- package/src/permissions/defaults.ts +3 -3
- package/src/permissions/trust-client.ts +2 -13
- package/src/permissions/trust-store.ts +8 -3
- package/src/runtime/auth/route-policy.ts +14 -0
- package/src/runtime/auth/token-service.ts +133 -0
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/routes/conversation-management-routes.ts +0 -36
- package/src/runtime/routes/conversation-query-routes.ts +44 -2
- package/src/runtime/routes/conversation-routes.ts +2 -1
- package/src/runtime/routes/memory-item-routes.test.ts +221 -3
- package/src/runtime/routes/memory-item-routes.ts +124 -2
- package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
- package/src/schedule/schedule-store.ts +0 -21
- package/src/skills/inline-command-render.ts +5 -1
- package/src/skills/inline-command-runner.ts +30 -2
- package/src/tools/memory/handlers.ts +1 -129
- package/src/tools/permission-checker.ts +18 -0
- package/src/tools/skills/load.ts +9 -2
- package/src/util/platform.ts +5 -5
- package/src/util/xml.ts +8 -0
- package/src/workspace/heartbeat-service.ts +5 -24
- package/src/__tests__/archive-recall.test.ts +0 -560
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
- package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
- package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
- package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
- package/src/__tests__/memory-brief-time.test.ts +0 -285
- package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
- package/src/__tests__/memory-chunk-archive.test.ts +0 -400
- package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
- package/src/__tests__/memory-episode-archive.test.ts +0 -370
- package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
- package/src/__tests__/memory-observation-archive.test.ts +0 -375
- package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
- package/src/__tests__/memory-reducer-job.test.ts +0 -538
- package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
- package/src/__tests__/memory-reducer-store.test.ts +0 -728
- package/src/__tests__/memory-reducer-types.test.ts +0 -707
- package/src/__tests__/memory-reducer.test.ts +0 -704
- package/src/__tests__/memory-simplified-config.test.ts +0 -281
- package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
- package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
- package/src/config/schemas/memory-simplified.ts +0 -101
- package/src/memory/archive-recall.ts +0 -516
- package/src/memory/archive-store.ts +0 -400
- package/src/memory/brief-formatting.ts +0 -33
- package/src/memory/brief-open-loops.ts +0 -266
- package/src/memory/brief-time.ts +0 -162
- package/src/memory/brief.ts +0 -75
- package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
- package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
- package/src/memory/migrations/185-memory-brief-state.ts +0 -52
- package/src/memory/migrations/186-memory-archive.ts +0 -109
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
- package/src/memory/reducer-scheduler.ts +0 -242
- package/src/memory/reducer-store.ts +0 -271
- package/src/memory/reducer-types.ts +0 -106
- package/src/memory/reducer.ts +0 -467
- package/src/memory/schema/memory-archive.ts +0 -121
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3231
|
-
|
|
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
|
|
3414
|
-
|
|
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 };
|
|
@@ -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"}". ` +
|