@vellumai/assistant 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docker-entrypoint.sh +12 -2
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
- package/openapi.yaml +1 -1
- package/package.json +1 -1
- package/src/__tests__/assistant-event-hub.test.ts +30 -0
- package/src/__tests__/checker.test.ts +104 -170
- package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
- package/src/__tests__/context-overflow-approval.test.ts +5 -5
- package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
- package/src/__tests__/conversation-directories-parse.test.ts +105 -0
- package/src/__tests__/credential-execution-approval-bridge.test.ts +0 -2
- package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
- package/src/__tests__/inline-command-runner.test.ts +7 -5
- package/src/__tests__/log-export-workspace.test.ts +190 -0
- package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
- package/src/__tests__/navigate-settings-tab.test.ts +14 -1
- package/src/__tests__/notification-broadcaster.test.ts +65 -0
- package/src/__tests__/onboarding-template-contract.test.ts +5 -4
- package/src/__tests__/pkb-autoinject.test.ts +96 -0
- package/src/__tests__/require-fresh-approval.test.ts +0 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
- package/src/__tests__/terminal-sandbox.test.ts +1 -1
- package/src/__tests__/terminal-tools.test.ts +2 -5
- package/src/__tests__/test-preload.ts +14 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
- package/src/__tests__/tool-executor.test.ts +0 -1
- package/src/__tests__/transport-hints-queue.test.ts +77 -0
- package/src/__tests__/trust-store.test.ts +4 -4
- package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
- package/src/__tests__/workspace-policy.test.ts +2 -7
- package/src/agent/loop.ts +0 -29
- package/src/channels/types.ts +5 -0
- package/src/cli/__tests__/run-assistant-command.ts +34 -7
- package/src/cli/__tests__/unknown-command.test.ts +33 -0
- package/src/cli/commands/default-action.ts +68 -1
- package/src/cli/commands/oauth/__tests__/connect.test.ts +27 -0
- package/src/cli/commands/oauth/connect.ts +11 -0
- package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
- package/src/cli/program.ts +9 -2
- package/src/config/assistant-feature-flags.ts +59 -55
- package/src/config/bundled-skills/app-builder/SKILL.md +87 -4
- package/src/config/bundled-skills/gmail/SKILL.md +11 -6
- package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
- package/src/config/bundled-skills/settings/TOOLS.json +1 -1
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
- package/src/config/feature-flag-registry.json +2 -2
- package/src/config/schemas/services.ts +8 -0
- package/src/credential-execution/approval-bridge.ts +0 -1
- package/src/credential-execution/managed-catalog.ts +3 -7
- package/src/daemon/config-watcher.ts +6 -2
- package/src/daemon/context-overflow-approval.ts +0 -1
- package/src/daemon/conversation-agent-loop.ts +33 -12
- package/src/daemon/conversation-attachments.ts +0 -1
- package/src/daemon/conversation-messaging.ts +3 -0
- package/src/daemon/conversation-process.ts +18 -2
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +64 -7
- package/src/daemon/conversation-surfaces.ts +65 -0
- package/src/daemon/conversation-tool-setup.ts +0 -3
- package/src/daemon/conversation.ts +3 -5
- package/src/daemon/handlers/conversations.ts +2 -1
- package/src/daemon/handlers/shared.ts +7 -0
- package/src/daemon/lifecycle.ts +21 -1
- package/src/daemon/message-types/conversations.ts +4 -0
- package/src/daemon/message-types/messages.ts +0 -1
- package/src/daemon/message-types/notifications.ts +12 -0
- package/src/daemon/message-types/settings.ts +12 -0
- package/src/daemon/server.ts +21 -24
- package/src/daemon/transport-hints.ts +33 -0
- package/src/index.ts +1 -1
- package/src/memory/conversation-crud.ts +15 -10
- package/src/memory/conversation-directories.ts +39 -0
- package/src/memory/conversation-group-migration.ts +65 -5
- package/src/memory/embedding-local.ts +1 -1
- package/src/memory/graph/capability-seed.ts +3 -5
- package/src/memory/group-crud.ts +25 -9
- package/src/messaging/provider.ts +1 -1
- package/src/notifications/broadcaster.ts +6 -0
- package/src/notifications/conversation-pairing.ts +12 -4
- package/src/notifications/emit-signal.ts +14 -0
- package/src/notifications/signal.ts +11 -0
- package/src/oauth/platform-connection.test.ts +2 -2
- package/src/oauth/seed-providers.ts +1 -0
- package/src/permissions/checker.ts +3 -3
- package/src/permissions/defaults.ts +7 -8
- package/src/permissions/prompter.ts +0 -2
- package/src/platform/client.ts +1 -1
- package/src/prompts/templates/BOOTSTRAP.md +14 -5
- package/src/prompts/templates/SOUL.md +11 -11
- package/src/runtime/assistant-event-hub.ts +22 -0
- package/src/runtime/auth/token-service.ts +8 -0
- package/src/runtime/routes/conversation-analysis-routes.ts +18 -6
- package/src/runtime/routes/conversation-routes.ts +9 -3
- package/src/runtime/routes/group-routes.ts +22 -8
- package/src/runtime/routes/log-export/AGENTS.md +104 -0
- package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
- package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
- package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
- package/src/runtime/routes/log-export-routes.ts +18 -3
- package/src/skills/inline-command-runner.ts +12 -14
- package/src/tools/permission-checker.ts +0 -18
- package/src/tools/secret-detection-handler.ts +0 -1
- package/src/tools/skills/sandbox-runner.ts +3 -6
- package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
- package/src/tools/terminal/sandbox.ts +4 -1
- package/src/tools/terminal/shell.ts +3 -5
- package/src/tools/types.ts +0 -3
- package/src/watcher/provider-types.ts +1 -1
- package/src/workspace/migrations/029-seed-pkb.ts +1 -0
- package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("../util/logger.js", () => ({
|
|
4
|
+
getLogger: () =>
|
|
5
|
+
new Proxy({} as Record<string, unknown>, {
|
|
6
|
+
get: () => () => {},
|
|
7
|
+
}),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const mockResolveConversationId = mock((id: string) => id);
|
|
11
|
+
const mockGetConversation = mock(() => ({
|
|
12
|
+
id: "conv-1",
|
|
13
|
+
title: "Source",
|
|
14
|
+
conversationType: "normal",
|
|
15
|
+
}));
|
|
16
|
+
const mockGetMessages = mock(() => [{ id: "m-source" }]);
|
|
17
|
+
const mockCreateConversation = mock(() => ({ id: "analysis-1" }));
|
|
18
|
+
const mockAddMessage = mock(async () => ({ id: "msg-1" }));
|
|
19
|
+
|
|
20
|
+
mock.module("../memory/conversation-key-store.js", () => ({
|
|
21
|
+
resolveConversationId: mockResolveConversationId,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../memory/conversation-crud.js", () => ({
|
|
25
|
+
getConversation: mockGetConversation,
|
|
26
|
+
getMessages: mockGetMessages,
|
|
27
|
+
createConversation: mockCreateConversation,
|
|
28
|
+
addMessage: mockAddMessage,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
mock.module("../export/transcript-formatter.js", () => ({
|
|
32
|
+
buildAnalysisTranscript: () => "user: hi",
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
|
|
36
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
37
|
+
import type { SendMessageDeps } from "../runtime/http-types.js";
|
|
38
|
+
import { conversationAnalysisRouteDefinitions } from "../runtime/routes/conversation-analysis-routes.js";
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mockResolveConversationId.mockClear();
|
|
42
|
+
mockGetConversation.mockClear();
|
|
43
|
+
mockGetMessages.mockClear();
|
|
44
|
+
mockCreateConversation.mockClear();
|
|
45
|
+
mockAddMessage.mockClear();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function makeConversation() {
|
|
49
|
+
return {
|
|
50
|
+
setTrustContext: mock(() => {}),
|
|
51
|
+
ensureActorScopedHistory: mock(() => Promise.resolve()),
|
|
52
|
+
setSubagentAllowedTools: mock(() => {}),
|
|
53
|
+
updateClient: mock(() => {}),
|
|
54
|
+
processing: false,
|
|
55
|
+
abortController: null as AbortController | null,
|
|
56
|
+
currentRequestId: null as string | null,
|
|
57
|
+
runAgentLoop: mock(() => Promise.resolve()),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("POST /v1/conversations/:id/analyze", () => {
|
|
62
|
+
test("runs headless analysis with unknown trust and no tools when no subscriber is present", async () => {
|
|
63
|
+
const conversation = makeConversation();
|
|
64
|
+
const assistantEventHub = new AssistantEventHub();
|
|
65
|
+
const sendMessageDeps = {
|
|
66
|
+
getOrCreateConversation: mock(async () => conversation),
|
|
67
|
+
assistantEventHub,
|
|
68
|
+
resolveAttachments: () => [],
|
|
69
|
+
} as unknown as SendMessageDeps;
|
|
70
|
+
|
|
71
|
+
const routes = conversationAnalysisRouteDefinitions({
|
|
72
|
+
sendMessageDeps,
|
|
73
|
+
buildConversationDetailResponse: () => ({ id: "analysis-1" }),
|
|
74
|
+
});
|
|
75
|
+
const route = routes.find(
|
|
76
|
+
(r) => r.method === "POST" && r.endpoint === "conversations/:id/analyze",
|
|
77
|
+
);
|
|
78
|
+
if (!route) throw new Error("analyze route missing");
|
|
79
|
+
|
|
80
|
+
const req = new Request("http://localhost/v1/conversations/conv-1/analyze", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const res = await route.handler({
|
|
85
|
+
req,
|
|
86
|
+
url: new URL(req.url),
|
|
87
|
+
server: null as never,
|
|
88
|
+
authContext: {} as never,
|
|
89
|
+
params: { id: "conv-1" },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
expect(mockAddMessage).toHaveBeenCalledWith(
|
|
94
|
+
"analysis-1",
|
|
95
|
+
"user",
|
|
96
|
+
expect.any(String),
|
|
97
|
+
{ provenanceTrustClass: "unknown" },
|
|
98
|
+
);
|
|
99
|
+
expect(conversation.setTrustContext).toHaveBeenCalledWith({
|
|
100
|
+
trustClass: "unknown",
|
|
101
|
+
sourceChannel: "vellum",
|
|
102
|
+
});
|
|
103
|
+
expect(conversation.ensureActorScopedHistory).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(conversation.setSubagentAllowedTools).toHaveBeenCalledTimes(1);
|
|
105
|
+
const allowedTools = (
|
|
106
|
+
conversation.setSubagentAllowedTools.mock.calls as unknown as Array<
|
|
107
|
+
[Set<string> | undefined]
|
|
108
|
+
>
|
|
109
|
+
)[0]?.[0];
|
|
110
|
+
expect(allowedTools).toBeInstanceOf(Set);
|
|
111
|
+
expect(allowedTools?.size).toBe(0);
|
|
112
|
+
expect(conversation.updateClient).toHaveBeenCalledWith(
|
|
113
|
+
expect.any(Function),
|
|
114
|
+
true,
|
|
115
|
+
);
|
|
116
|
+
expect(conversation.runAgentLoop).toHaveBeenCalledWith(
|
|
117
|
+
expect.any(String),
|
|
118
|
+
"msg-1",
|
|
119
|
+
expect.any(Function),
|
|
120
|
+
expect.objectContaining({ isInteractive: false, isUserMessage: true }),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("marks analysis interactive when a matching subscriber is already connected", async () => {
|
|
125
|
+
const conversation = makeConversation();
|
|
126
|
+
const assistantEventHub = new AssistantEventHub();
|
|
127
|
+
assistantEventHub.subscribe(
|
|
128
|
+
{ assistantId: DAEMON_INTERNAL_ASSISTANT_ID },
|
|
129
|
+
() => {},
|
|
130
|
+
);
|
|
131
|
+
const sendMessageDeps = {
|
|
132
|
+
getOrCreateConversation: mock(async () => conversation),
|
|
133
|
+
assistantEventHub,
|
|
134
|
+
resolveAttachments: () => [],
|
|
135
|
+
} as unknown as SendMessageDeps;
|
|
136
|
+
|
|
137
|
+
const routes = conversationAnalysisRouteDefinitions({
|
|
138
|
+
sendMessageDeps,
|
|
139
|
+
buildConversationDetailResponse: () => ({ id: "analysis-1" }),
|
|
140
|
+
});
|
|
141
|
+
const route = routes.find(
|
|
142
|
+
(r) => r.method === "POST" && r.endpoint === "conversations/:id/analyze",
|
|
143
|
+
);
|
|
144
|
+
if (!route) throw new Error("analyze route missing");
|
|
145
|
+
|
|
146
|
+
const req = new Request("http://localhost/v1/conversations/conv-1/analyze", {
|
|
147
|
+
method: "POST",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await route.handler({
|
|
151
|
+
req,
|
|
152
|
+
url: new URL(req.url),
|
|
153
|
+
server: null as never,
|
|
154
|
+
authContext: {} as never,
|
|
155
|
+
params: { id: "conv-1" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(conversation.updateClient).toHaveBeenCalledWith(
|
|
159
|
+
expect.any(Function),
|
|
160
|
+
false,
|
|
161
|
+
);
|
|
162
|
+
expect(conversation.runAgentLoop).toHaveBeenCalledWith(
|
|
163
|
+
expect.any(String),
|
|
164
|
+
"msg-1",
|
|
165
|
+
expect.any(Function),
|
|
166
|
+
expect.objectContaining({ isInteractive: true, isUserMessage: true }),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getConversationDirName,
|
|
5
|
+
parseConversationDirName,
|
|
6
|
+
} from "../memory/conversation-directories.js";
|
|
7
|
+
|
|
8
|
+
describe("parseConversationDirName", () => {
|
|
9
|
+
describe("round-trip with getConversationDirName", () => {
|
|
10
|
+
test("round-trips a UUID-shaped id", () => {
|
|
11
|
+
const id = "4ae7ea90-86e4-446a-8673-7bba94ecfea1";
|
|
12
|
+
const createdAtMs = Date.parse("2026-04-07T10:47:23.075Z");
|
|
13
|
+
const name = getConversationDirName(id, createdAtMs);
|
|
14
|
+
const parsed = parseConversationDirName(name);
|
|
15
|
+
expect(parsed).toEqual({ conversationId: id, createdAtMs });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("round-trips an id with embedded hyphens", () => {
|
|
19
|
+
const id = "conv-with-hyphens-123";
|
|
20
|
+
const createdAtMs = Date.parse("2024-01-15T00:00:00.000Z");
|
|
21
|
+
const name = getConversationDirName(id, createdAtMs);
|
|
22
|
+
const parsed = parseConversationDirName(name);
|
|
23
|
+
expect(parsed).toEqual({ conversationId: id, createdAtMs });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("round-trips an id with embedded underscores", () => {
|
|
27
|
+
const id = "foo_bar_baz";
|
|
28
|
+
const createdAtMs = Date.parse("2025-12-31T23:59:59.999Z");
|
|
29
|
+
const name = getConversationDirName(id, createdAtMs);
|
|
30
|
+
const parsed = parseConversationDirName(name);
|
|
31
|
+
expect(parsed).toEqual({ conversationId: id, createdAtMs });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("exact parsing against literal example", () => {
|
|
36
|
+
test("parses the canonical example from the spec", () => {
|
|
37
|
+
const name =
|
|
38
|
+
"2026-04-07T10-47-23.075Z_4ae7ea90-86e4-446a-8673-7bba94ecfea1";
|
|
39
|
+
const parsed = parseConversationDirName(name);
|
|
40
|
+
expect(parsed).not.toBeNull();
|
|
41
|
+
expect(parsed?.conversationId).toBe(
|
|
42
|
+
"4ae7ea90-86e4-446a-8673-7bba94ecfea1",
|
|
43
|
+
);
|
|
44
|
+
expect(parsed?.createdAtMs).toBe(Date.parse("2026-04-07T10:47:23.075Z"));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("returns null for malformed input", () => {
|
|
49
|
+
test("returns null for empty string", () => {
|
|
50
|
+
expect(parseConversationDirName("")).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns null for missing underscore", () => {
|
|
54
|
+
expect(parseConversationDirName("2026-04-07T10-47-23.075Z")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("returns null for legacy format (id first, timestamp second)", () => {
|
|
58
|
+
expect(
|
|
59
|
+
parseConversationDirName(
|
|
60
|
+
"4ae7ea90-86e4-446a-8673-7bba94ecfea1_2026-04-07T10-47-23.075Z",
|
|
61
|
+
),
|
|
62
|
+
).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns null for non-ISO prefix", () => {
|
|
66
|
+
expect(parseConversationDirName("hello_world")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns null for random garbage", () => {
|
|
70
|
+
expect(parseConversationDirName("drafts/foo")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns null when the conversation id is '.'", () => {
|
|
74
|
+
expect(parseConversationDirName("2025-01-15T00-00-00.000Z_.")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns null when the conversation id is '..'", () => {
|
|
78
|
+
expect(
|
|
79
|
+
parseConversationDirName("2025-01-15T00-00-00.000Z_.."),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("returns null when the conversation id contains a forward slash", () => {
|
|
84
|
+
expect(
|
|
85
|
+
parseConversationDirName("2025-01-15T00-00-00.000Z_foo/bar"),
|
|
86
|
+
).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("returns null when the conversation id contains a backslash", () => {
|
|
90
|
+
expect(
|
|
91
|
+
parseConversationDirName("2025-01-15T00-00-00.000Z_foo\\bar"),
|
|
92
|
+
).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("ids containing underscores", () => {
|
|
97
|
+
test("captures everything after the timestamp as the conversationId", () => {
|
|
98
|
+
const name = "2026-04-07T10-47-23.075Z_my_test_id";
|
|
99
|
+
const parsed = parseConversationDirName(name);
|
|
100
|
+
expect(parsed).not.toBeNull();
|
|
101
|
+
expect(parsed?.conversationId).toBe("my_test_id");
|
|
102
|
+
expect(parsed?.createdAtMs).toBe(Date.parse("2026-04-07T10:47:23.075Z"));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -95,7 +95,6 @@ function makePrompter(
|
|
|
95
95
|
allowlistOptions: unknown[],
|
|
96
96
|
scopeOptions: unknown[],
|
|
97
97
|
diff: unknown,
|
|
98
|
-
sandboxed: unknown,
|
|
99
98
|
sessionId: string | undefined,
|
|
100
99
|
executionTarget: unknown,
|
|
101
100
|
persistentDecisionsAllowed: unknown,
|
|
@@ -109,7 +108,6 @@ function makePrompter(
|
|
|
109
108
|
allowlistOptions,
|
|
110
109
|
scopeOptions,
|
|
111
110
|
diff,
|
|
112
|
-
sandboxed,
|
|
113
111
|
sessionId,
|
|
114
112
|
executionTarget,
|
|
115
113
|
persistentDecisionsAllowed,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for initFeatureFlagOverrides() — the async gateway fetch that
|
|
3
|
+
* pre-populates the feature flag cache before CLI program construction.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
clearFeatureFlagOverridesCache,
|
|
9
|
+
initFeatureFlagOverrides,
|
|
10
|
+
isAssistantFeatureFlagEnabled,
|
|
11
|
+
} from "../config/assistant-feature-flags.js";
|
|
12
|
+
import * as tokenService from "../runtime/auth/token-service.js";
|
|
13
|
+
import { getMockFetchCalls, mockFetch, resetMockFetch } from "./mock-fetch.js";
|
|
14
|
+
|
|
15
|
+
const VALID_HEX_KEY = "ab".repeat(32);
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
clearFeatureFlagOverridesCache();
|
|
19
|
+
tokenService._resetSigningKeyForTesting();
|
|
20
|
+
|
|
21
|
+
// Set up a signing key so mintEdgeRelayToken() works
|
|
22
|
+
process.env.ACTOR_TOKEN_SIGNING_KEY = VALID_HEX_KEY;
|
|
23
|
+
tokenService.initAuthSigningKey(tokenService.resolveSigningKey());
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
resetMockFetch();
|
|
28
|
+
clearFeatureFlagOverridesCache();
|
|
29
|
+
tokenService._resetSigningKeyForTesting();
|
|
30
|
+
delete process.env.ACTOR_TOKEN_SIGNING_KEY;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("initFeatureFlagOverrides", () => {
|
|
34
|
+
it("populates cache from gateway fetch response", async () => {
|
|
35
|
+
mockFetch(
|
|
36
|
+
"/v1/feature-flags",
|
|
37
|
+
{ method: "GET" },
|
|
38
|
+
{
|
|
39
|
+
body: {
|
|
40
|
+
flags: [
|
|
41
|
+
{
|
|
42
|
+
key: "foo-enabled",
|
|
43
|
+
enabled: true,
|
|
44
|
+
label: "Foo",
|
|
45
|
+
defaultEnabled: false,
|
|
46
|
+
description: "",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "bar-enabled",
|
|
50
|
+
enabled: true,
|
|
51
|
+
label: "Bar",
|
|
52
|
+
defaultEnabled: true,
|
|
53
|
+
description: "",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
status: 200,
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await initFeatureFlagOverrides();
|
|
62
|
+
|
|
63
|
+
const config = {} as any;
|
|
64
|
+
expect(isAssistantFeatureFlagEnabled("foo-enabled", config)).toBe(true);
|
|
65
|
+
expect(isAssistantFeatureFlagEnabled("bar-enabled", config)).toBe(true);
|
|
66
|
+
|
|
67
|
+
// Verify fetch was called with correct URL and auth header
|
|
68
|
+
const calls = getMockFetchCalls();
|
|
69
|
+
expect(calls.length).toBe(1);
|
|
70
|
+
expect(calls[0].path).toContain("/v1/feature-flags");
|
|
71
|
+
const headers = calls[0].init.headers as Record<string, string> | undefined;
|
|
72
|
+
expect(headers).toHaveProperty("Authorization");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("sends a valid Bearer JWT in the Authorization header", async () => {
|
|
76
|
+
mockFetch(
|
|
77
|
+
"/v1/feature-flags",
|
|
78
|
+
{ method: "GET" },
|
|
79
|
+
{ body: { flags: [] }, status: 200 },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
await initFeatureFlagOverrides();
|
|
83
|
+
|
|
84
|
+
const calls = getMockFetchCalls();
|
|
85
|
+
expect(calls.length).toBe(1);
|
|
86
|
+
const headers = calls[0].init.headers as Record<string, string> | undefined;
|
|
87
|
+
const authHeader = headers?.Authorization;
|
|
88
|
+
|
|
89
|
+
expect(authHeader).toBeDefined();
|
|
90
|
+
expect(authHeader).toMatch(/^Bearer /);
|
|
91
|
+
|
|
92
|
+
// Verify it's a valid JWT (three dot-separated base64url segments)
|
|
93
|
+
const token = authHeader!.replace("Bearer ", "");
|
|
94
|
+
const parts = token.split(".");
|
|
95
|
+
expect(parts.length).toBe(3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back gracefully when gateway is unreachable", async () => {
|
|
99
|
+
mockFetch("/v1/feature-flags", { method: "GET" }, { status: 500 });
|
|
100
|
+
|
|
101
|
+
// Should not throw
|
|
102
|
+
await initFeatureFlagOverrides();
|
|
103
|
+
|
|
104
|
+
// Without gateway data or file, undeclared flags default to true
|
|
105
|
+
const config = {} as any;
|
|
106
|
+
expect(isAssistantFeatureFlagEnabled("foo-enabled", config)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("falls back gracefully on non-OK HTTP status", async () => {
|
|
110
|
+
mockFetch(
|
|
111
|
+
"/v1/feature-flags",
|
|
112
|
+
{ method: "GET" },
|
|
113
|
+
{ body: "Unauthorized", status: 401 },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await initFeatureFlagOverrides();
|
|
117
|
+
|
|
118
|
+
// Undeclared flags default to true without overrides
|
|
119
|
+
const config = {} as any;
|
|
120
|
+
expect(isAssistantFeatureFlagEnabled("foo-enabled", config)).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("initializes signing key lazily when not yet set", async () => {
|
|
124
|
+
// Reset signing key to simulate fresh CLI subprocess
|
|
125
|
+
tokenService._resetSigningKeyForTesting();
|
|
126
|
+
delete process.env.ACTOR_TOKEN_SIGNING_KEY;
|
|
127
|
+
|
|
128
|
+
expect(tokenService.isSigningKeyInitialized()).toBe(false);
|
|
129
|
+
|
|
130
|
+
mockFetch(
|
|
131
|
+
"/v1/feature-flags",
|
|
132
|
+
{ method: "GET" },
|
|
133
|
+
{
|
|
134
|
+
body: {
|
|
135
|
+
flags: [{ key: "expected-enabled", enabled: true }],
|
|
136
|
+
},
|
|
137
|
+
status: 200,
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
await initFeatureFlagOverrides();
|
|
142
|
+
|
|
143
|
+
// Signing key should have been initialized during the fetch
|
|
144
|
+
expect(tokenService.isSigningKeyInitialized()).toBe(true);
|
|
145
|
+
|
|
146
|
+
// And the flag should be resolved correctly
|
|
147
|
+
const config = {} as any;
|
|
148
|
+
expect(isAssistantFeatureFlagEnabled("expected-enabled", config)).toBe(
|
|
149
|
+
true,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not cache empty gateway response", async () => {
|
|
154
|
+
mockFetch(
|
|
155
|
+
"/v1/feature-flags",
|
|
156
|
+
{ method: "GET" },
|
|
157
|
+
{ body: { flags: [] }, status: 200 },
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
await initFeatureFlagOverrides();
|
|
161
|
+
|
|
162
|
+
// Undeclared flags without overrides default to true (not false from
|
|
163
|
+
// a cached empty map)
|
|
164
|
+
const config = {} as any;
|
|
165
|
+
expect(isAssistantFeatureFlagEnabled("foo-enabled", config)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -11,7 +11,6 @@ const mockConfig = {
|
|
|
11
11
|
model: "test",
|
|
12
12
|
maxTokens: 4096,
|
|
13
13
|
dataDir: "/tmp",
|
|
14
|
-
sandbox: { enabled: true },
|
|
15
14
|
timeouts: {
|
|
16
15
|
shellDefaultTimeoutSec: 120,
|
|
17
16
|
shellMaxTimeoutSec: 600,
|
|
@@ -104,20 +103,23 @@ describe("runInlineCommand", () => {
|
|
|
104
103
|
// ── Sandbox enforcement ──────────────────────────────────────────────────
|
|
105
104
|
|
|
106
105
|
describe("sandbox enforcement", () => {
|
|
107
|
-
test("always passes sandbox config with enabled=
|
|
106
|
+
test("always passes sandbox config with enabled=false", async () => {
|
|
108
107
|
lastWrapCall = null;
|
|
109
108
|
await runInlineCommand("echo sandbox-check", CWD);
|
|
110
109
|
|
|
111
110
|
expect(lastWrapCall).not.toBeNull();
|
|
112
|
-
expect(lastWrapCall!.config.enabled).toBe(
|
|
111
|
+
expect(lastWrapCall!.config.enabled).toBe(false);
|
|
113
112
|
});
|
|
114
113
|
|
|
115
|
-
test("
|
|
114
|
+
test("does not pass networkMode when sandbox is disabled", async () => {
|
|
116
115
|
lastWrapCall = null;
|
|
117
116
|
await runInlineCommand("echo network-check", CWD);
|
|
118
117
|
|
|
119
118
|
expect(lastWrapCall).not.toBeNull();
|
|
120
|
-
|
|
119
|
+
// networkMode is a no-op when sandbox is disabled (wrapCommand returns
|
|
120
|
+
// a plain bash invocation), so it is not passed. Network isolation is
|
|
121
|
+
// provided by the Docker/platform-managed container.
|
|
122
|
+
expect(lastWrapCall!.options).toBeUndefined();
|
|
121
123
|
});
|
|
122
124
|
|
|
123
125
|
test("uses the provided workingDir as cwd", async () => {
|
|
@@ -93,6 +93,35 @@ writeFileSync(
|
|
|
93
93
|
JSON.stringify({ provider: "anthropic" }),
|
|
94
94
|
);
|
|
95
95
|
|
|
96
|
+
// Conversation directories — used for workspace allowlist tests
|
|
97
|
+
const conversationsDir = join(testWorkspaceDir, "conversations");
|
|
98
|
+
mkdirSync(conversationsDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
function seedConversation(name: string, body: string) {
|
|
101
|
+
const dir = join(conversationsDir, name);
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
writeFileSync(join(dir, "meta.json"), "{}\n");
|
|
104
|
+
writeFileSync(join(dir, "messages.jsonl"), body);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
seedConversation(
|
|
108
|
+
"2025-01-10T00-00-00.000Z_conv-jan10",
|
|
109
|
+
'{"role":"user","content":"jan 10"}\n',
|
|
110
|
+
);
|
|
111
|
+
seedConversation(
|
|
112
|
+
"2025-01-15T00-00-00.000Z_conv-jan15",
|
|
113
|
+
'{"role":"user","content":"jan 15"}\n',
|
|
114
|
+
);
|
|
115
|
+
seedConversation(
|
|
116
|
+
"2025-01-20T00-00-00.000Z_conv-jan20",
|
|
117
|
+
'{"role":"user","content":"jan 20"}\n',
|
|
118
|
+
);
|
|
119
|
+
seedConversation(
|
|
120
|
+
"2025-01-25T00-00-00.000Z_conv-jan25",
|
|
121
|
+
'{"role":"user","content":"jan 25"}\n',
|
|
122
|
+
);
|
|
123
|
+
seedConversation("malformed-name", '{"role":"user","content":"x"}\n');
|
|
124
|
+
|
|
96
125
|
// Daemon log files — used for date filtering tests
|
|
97
126
|
const logsDir = join(testWorkspaceDir, "data", "logs");
|
|
98
127
|
mkdirSync(logsDir, { recursive: true });
|
|
@@ -241,3 +270,164 @@ describe("POST /v1/export — daemon log date filtering", () => {
|
|
|
241
270
|
}
|
|
242
271
|
});
|
|
243
272
|
});
|
|
273
|
+
|
|
274
|
+
describe("POST /v1/export — workspace allowlist", () => {
|
|
275
|
+
test("includes all valid conversation dirs by default", async () => {
|
|
276
|
+
const res = await callExport();
|
|
277
|
+
const dir = await extractArchive(res);
|
|
278
|
+
try {
|
|
279
|
+
const convs = readdirSync(join(dir, "workspace", "conversations"));
|
|
280
|
+
expect(convs).toContain("2025-01-10T00-00-00.000Z_conv-jan10");
|
|
281
|
+
expect(convs).toContain("2025-01-15T00-00-00.000Z_conv-jan15");
|
|
282
|
+
expect(convs).toContain("2025-01-20T00-00-00.000Z_conv-jan20");
|
|
283
|
+
expect(convs).toContain("2025-01-25T00-00-00.000Z_conv-jan25");
|
|
284
|
+
expect(convs).not.toContain("malformed-name");
|
|
285
|
+
} finally {
|
|
286
|
+
rmSync(dir, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("skips malformed conversation dir names", async () => {
|
|
291
|
+
const res = await callExport();
|
|
292
|
+
const dir = await extractArchive(res);
|
|
293
|
+
try {
|
|
294
|
+
const convs = readdirSync(join(dir, "workspace", "conversations"));
|
|
295
|
+
expect(convs).not.toContain("malformed-name");
|
|
296
|
+
} finally {
|
|
297
|
+
rmSync(dir, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("filters conversation dirs by startTime", async () => {
|
|
302
|
+
const startTime = Date.parse("2025-01-14T00:00:00Z");
|
|
303
|
+
const res = await callExport({ startTime });
|
|
304
|
+
const dir = await extractArchive(res);
|
|
305
|
+
try {
|
|
306
|
+
const convs = readdirSync(join(dir, "workspace", "conversations"));
|
|
307
|
+
expect(convs).not.toContain("2025-01-10T00-00-00.000Z_conv-jan10");
|
|
308
|
+
expect(convs).toContain("2025-01-15T00-00-00.000Z_conv-jan15");
|
|
309
|
+
expect(convs).toContain("2025-01-20T00-00-00.000Z_conv-jan20");
|
|
310
|
+
expect(convs).toContain("2025-01-25T00-00-00.000Z_conv-jan25");
|
|
311
|
+
} finally {
|
|
312
|
+
rmSync(dir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("filters conversation dirs by endTime", async () => {
|
|
317
|
+
const endTime = Date.parse("2025-01-22T00:00:00Z");
|
|
318
|
+
const res = await callExport({ endTime });
|
|
319
|
+
const dir = await extractArchive(res);
|
|
320
|
+
try {
|
|
321
|
+
const convs = readdirSync(join(dir, "workspace", "conversations"));
|
|
322
|
+
expect(convs).toContain("2025-01-10T00-00-00.000Z_conv-jan10");
|
|
323
|
+
expect(convs).toContain("2025-01-15T00-00-00.000Z_conv-jan15");
|
|
324
|
+
expect(convs).toContain("2025-01-20T00-00-00.000Z_conv-jan20");
|
|
325
|
+
expect(convs).not.toContain("2025-01-25T00-00-00.000Z_conv-jan25");
|
|
326
|
+
} finally {
|
|
327
|
+
rmSync(dir, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("filters conversation dirs by both startTime and endTime", async () => {
|
|
332
|
+
const startTime = Date.parse("2025-01-14T00:00:00Z");
|
|
333
|
+
const endTime = Date.parse("2025-01-22T00:00:00Z");
|
|
334
|
+
const res = await callExport({ startTime, endTime });
|
|
335
|
+
const dir = await extractArchive(res);
|
|
336
|
+
try {
|
|
337
|
+
const convs = readdirSync(join(dir, "workspace", "conversations"));
|
|
338
|
+
expect(convs).not.toContain("2025-01-10T00-00-00.000Z_conv-jan10");
|
|
339
|
+
expect(convs).toContain("2025-01-15T00-00-00.000Z_conv-jan15");
|
|
340
|
+
expect(convs).toContain("2025-01-20T00-00-00.000Z_conv-jan20");
|
|
341
|
+
expect(convs).not.toContain("2025-01-25T00-00-00.000Z_conv-jan25");
|
|
342
|
+
} finally {
|
|
343
|
+
rmSync(dir, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("filters conversation dirs by conversationId", async () => {
|
|
348
|
+
const res = await callExport({ conversationId: "conv-jan15" });
|
|
349
|
+
const dir = await extractArchive(res);
|
|
350
|
+
try {
|
|
351
|
+
const convs = readdirSync(join(dir, "workspace", "conversations"));
|
|
352
|
+
expect(convs).toContain("2025-01-15T00-00-00.000Z_conv-jan15");
|
|
353
|
+
expect(convs).not.toContain("2025-01-10T00-00-00.000Z_conv-jan10");
|
|
354
|
+
expect(convs).not.toContain("2025-01-20T00-00-00.000Z_conv-jan20");
|
|
355
|
+
expect(convs).not.toContain("2025-01-25T00-00-00.000Z_conv-jan25");
|
|
356
|
+
expect(convs).not.toContain("malformed-name");
|
|
357
|
+
} finally {
|
|
358
|
+
rmSync(dir, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("conversationId + time filter intersect", async () => {
|
|
363
|
+
const res = await callExport({
|
|
364
|
+
conversationId: "conv-jan15",
|
|
365
|
+
startTime: Date.parse("2025-02-01T00:00:00Z"),
|
|
366
|
+
});
|
|
367
|
+
const dir = await extractArchive(res);
|
|
368
|
+
try {
|
|
369
|
+
const conversationsPath = join(dir, "workspace", "conversations");
|
|
370
|
+
let convs: string[] = [];
|
|
371
|
+
try {
|
|
372
|
+
convs = readdirSync(conversationsPath);
|
|
373
|
+
} catch {
|
|
374
|
+
// Directory does not exist — acceptable per the test contract.
|
|
375
|
+
}
|
|
376
|
+
expect(convs).toEqual([]);
|
|
377
|
+
} finally {
|
|
378
|
+
rmSync(dir, { recursive: true, force: true });
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("conversation dir contents survive the round trip", async () => {
|
|
383
|
+
const res = await callExport();
|
|
384
|
+
const dir = await extractArchive(res);
|
|
385
|
+
try {
|
|
386
|
+
const messagesPath = join(
|
|
387
|
+
dir,
|
|
388
|
+
"workspace",
|
|
389
|
+
"conversations",
|
|
390
|
+
"2025-01-15T00-00-00.000Z_conv-jan15",
|
|
391
|
+
"messages.jsonl",
|
|
392
|
+
);
|
|
393
|
+
const content = readFileSync(messagesPath, "utf-8");
|
|
394
|
+
expect(content).toBe('{"role":"user","content":"jan 15"}\n');
|
|
395
|
+
} finally {
|
|
396
|
+
rmSync(dir, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("treats empty-string conversationId as no filter", async () => {
|
|
401
|
+
const res = await callExport({ conversationId: "" });
|
|
402
|
+
const dir = await extractArchive(res);
|
|
403
|
+
try {
|
|
404
|
+
// With conversationId === "" (which the rest of handleExport treats as
|
|
405
|
+
// unfiltered), workspace conversations should also be unfiltered. All
|
|
406
|
+
// four canonical conversation dirs should be present.
|
|
407
|
+
const conversationsDir = join(dir, "workspace", "conversations");
|
|
408
|
+
const entries = readdirSync(conversationsDir);
|
|
409
|
+
expect(entries).toContain("2025-01-10T00-00-00.000Z_conv-jan10");
|
|
410
|
+
expect(entries).toContain("2025-01-15T00-00-00.000Z_conv-jan15");
|
|
411
|
+
expect(entries).toContain("2025-01-20T00-00-00.000Z_conv-jan20");
|
|
412
|
+
expect(entries).toContain("2025-01-25T00-00-00.000Z_conv-jan25");
|
|
413
|
+
} finally {
|
|
414
|
+
rmSync(dir, { recursive: true, force: true });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("treats startTime=0 and endTime=0 as no filter", async () => {
|
|
419
|
+
const res = await callExport({ startTime: 0, endTime: 0 });
|
|
420
|
+
const dir = await extractArchive(res);
|
|
421
|
+
try {
|
|
422
|
+
const conversationsDir = join(dir, "workspace", "conversations");
|
|
423
|
+
const entries = readdirSync(conversationsDir);
|
|
424
|
+
// All four canonical conversation dirs should be present (no filtering).
|
|
425
|
+
expect(entries).toContain("2025-01-10T00-00-00.000Z_conv-jan10");
|
|
426
|
+
expect(entries).toContain("2025-01-15T00-00-00.000Z_conv-jan15");
|
|
427
|
+
expect(entries).toContain("2025-01-20T00-00-00.000Z_conv-jan20");
|
|
428
|
+
expect(entries).toContain("2025-01-25T00-00-00.000Z_conv-jan25");
|
|
429
|
+
} finally {
|
|
430
|
+
rmSync(dir, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|