@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.
- package/Dockerfile +17 -27
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- 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__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
- package/src/__tests__/memory-regressions.test.ts +8 -30
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/require-fresh-approval.test.ts +4 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +4 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/cli/commands/conversations.ts +0 -18
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/env.ts +8 -2
- package/src/config/feature-flag-registry.json +8 -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/credential-execution/managed-catalog.ts +5 -15
- 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/daemon-control.ts +7 -0
- package/src/daemon/handlers/conversations.ts +0 -11
- package/src/daemon/lifecycle.ts +10 -47
- package/src/daemon/providers-setup.ts +2 -1
- package/src/followups/followup-store.ts +5 -2
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- 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/embedding-local.ts +11 -5
- package/src/memory/indexer.ts +15 -106
- package/src/memory/job-handlers/conversation-starters.ts +24 -36
- 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/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/defaults.ts +3 -3
- package/src/permissions/trust-client.ts +332 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +531 -39
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/auth/route-policy.ts +14 -0
- package/src/runtime/auth/token-service.ts +133 -0
- package/src/runtime/http-server.ts +4 -2
- 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/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- 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/secret-routes.ts +4 -1
- package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
- package/src/schedule/schedule-store.ts +0 -21
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/skills/inline-command-render.ts +5 -1
- package/src/skills/inline-command-runner.ts +30 -2
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +21 -19
- 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/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/util/xml.ts +8 -0
- package/src/workspace/heartbeat-service.ts +5 -24
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- package/src/workspace/migrations/registry.ts +2 -0
- 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,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
|
|
62
|
-
RUN mkdir -p /home/assistant/.vellum
|
|
63
|
-
chown -R assistant:assistant /home/assistant/.vellum
|
|
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
|
|
75
|
+
ENV PATH="/home/assistant/.bun/bin:${PATH}"
|
|
68
76
|
|
|
69
|
-
# Configure package managers to use
|
|
70
|
-
ENV BUN_INSTALL="/
|
|
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="/
|
|
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
|
|
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
|
|
@@ -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
|
@@ -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
|
+
});
|