@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.
Files changed (115) hide show
  1. package/docker-entrypoint.sh +12 -2
  2. package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
  3. package/openapi.yaml +1 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-event-hub.test.ts +30 -0
  6. package/src/__tests__/checker.test.ts +104 -170
  7. package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
  8. package/src/__tests__/context-overflow-approval.test.ts +5 -5
  9. package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
  10. package/src/__tests__/conversation-directories-parse.test.ts +105 -0
  11. package/src/__tests__/credential-execution-approval-bridge.test.ts +0 -2
  12. package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
  13. package/src/__tests__/inline-command-runner.test.ts +7 -5
  14. package/src/__tests__/log-export-workspace.test.ts +190 -0
  15. package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
  16. package/src/__tests__/navigate-settings-tab.test.ts +14 -1
  17. package/src/__tests__/notification-broadcaster.test.ts +65 -0
  18. package/src/__tests__/onboarding-template-contract.test.ts +5 -4
  19. package/src/__tests__/pkb-autoinject.test.ts +96 -0
  20. package/src/__tests__/require-fresh-approval.test.ts +0 -2
  21. package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
  22. package/src/__tests__/terminal-sandbox.test.ts +1 -1
  23. package/src/__tests__/terminal-tools.test.ts +2 -5
  24. package/src/__tests__/test-preload.ts +14 -0
  25. package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
  27. package/src/__tests__/tool-executor.test.ts +0 -1
  28. package/src/__tests__/transport-hints-queue.test.ts +77 -0
  29. package/src/__tests__/trust-store.test.ts +4 -4
  30. package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
  31. package/src/__tests__/workspace-policy.test.ts +2 -7
  32. package/src/agent/loop.ts +0 -29
  33. package/src/channels/types.ts +5 -0
  34. package/src/cli/__tests__/run-assistant-command.ts +34 -7
  35. package/src/cli/__tests__/unknown-command.test.ts +33 -0
  36. package/src/cli/commands/default-action.ts +68 -1
  37. package/src/cli/commands/oauth/__tests__/connect.test.ts +27 -0
  38. package/src/cli/commands/oauth/connect.ts +11 -0
  39. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  40. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  41. package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
  42. package/src/cli/program.ts +9 -2
  43. package/src/config/assistant-feature-flags.ts +59 -55
  44. package/src/config/bundled-skills/app-builder/SKILL.md +87 -4
  45. package/src/config/bundled-skills/gmail/SKILL.md +11 -6
  46. package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
  47. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
  48. package/src/config/bundled-skills/settings/TOOLS.json +1 -1
  49. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
  50. package/src/config/feature-flag-registry.json +2 -2
  51. package/src/config/schemas/services.ts +8 -0
  52. package/src/credential-execution/approval-bridge.ts +0 -1
  53. package/src/credential-execution/managed-catalog.ts +3 -7
  54. package/src/daemon/config-watcher.ts +6 -2
  55. package/src/daemon/context-overflow-approval.ts +0 -1
  56. package/src/daemon/conversation-agent-loop.ts +33 -12
  57. package/src/daemon/conversation-attachments.ts +0 -1
  58. package/src/daemon/conversation-messaging.ts +3 -0
  59. package/src/daemon/conversation-process.ts +18 -2
  60. package/src/daemon/conversation-queue-manager.ts +8 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +64 -7
  62. package/src/daemon/conversation-surfaces.ts +65 -0
  63. package/src/daemon/conversation-tool-setup.ts +0 -3
  64. package/src/daemon/conversation.ts +3 -5
  65. package/src/daemon/handlers/conversations.ts +2 -1
  66. package/src/daemon/handlers/shared.ts +7 -0
  67. package/src/daemon/lifecycle.ts +21 -1
  68. package/src/daemon/message-types/conversations.ts +4 -0
  69. package/src/daemon/message-types/messages.ts +0 -1
  70. package/src/daemon/message-types/notifications.ts +12 -0
  71. package/src/daemon/message-types/settings.ts +12 -0
  72. package/src/daemon/server.ts +21 -24
  73. package/src/daemon/transport-hints.ts +33 -0
  74. package/src/index.ts +1 -1
  75. package/src/memory/conversation-crud.ts +15 -10
  76. package/src/memory/conversation-directories.ts +39 -0
  77. package/src/memory/conversation-group-migration.ts +65 -5
  78. package/src/memory/embedding-local.ts +1 -1
  79. package/src/memory/graph/capability-seed.ts +3 -5
  80. package/src/memory/group-crud.ts +25 -9
  81. package/src/messaging/provider.ts +1 -1
  82. package/src/notifications/broadcaster.ts +6 -0
  83. package/src/notifications/conversation-pairing.ts +12 -4
  84. package/src/notifications/emit-signal.ts +14 -0
  85. package/src/notifications/signal.ts +11 -0
  86. package/src/oauth/platform-connection.test.ts +2 -2
  87. package/src/oauth/seed-providers.ts +1 -0
  88. package/src/permissions/checker.ts +3 -3
  89. package/src/permissions/defaults.ts +7 -8
  90. package/src/permissions/prompter.ts +0 -2
  91. package/src/platform/client.ts +1 -1
  92. package/src/prompts/templates/BOOTSTRAP.md +14 -5
  93. package/src/prompts/templates/SOUL.md +11 -11
  94. package/src/runtime/assistant-event-hub.ts +22 -0
  95. package/src/runtime/auth/token-service.ts +8 -0
  96. package/src/runtime/routes/conversation-analysis-routes.ts +18 -6
  97. package/src/runtime/routes/conversation-routes.ts +9 -3
  98. package/src/runtime/routes/group-routes.ts +22 -8
  99. package/src/runtime/routes/log-export/AGENTS.md +104 -0
  100. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
  101. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
  102. package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
  103. package/src/runtime/routes/log-export-routes.ts +18 -3
  104. package/src/skills/inline-command-runner.ts +12 -14
  105. package/src/tools/permission-checker.ts +0 -18
  106. package/src/tools/secret-detection-handler.ts +0 -1
  107. package/src/tools/skills/sandbox-runner.ts +3 -6
  108. package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
  109. package/src/tools/terminal/sandbox.ts +4 -1
  110. package/src/tools/terminal/shell.ts +3 -5
  111. package/src/tools/types.ts +0 -3
  112. package/src/watcher/provider-types.ts +1 -1
  113. package/src/workspace/migrations/029-seed-pkb.ts +1 -0
  114. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
  115. 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=true", async () => {
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(true);
111
+ expect(lastWrapCall!.config.enabled).toBe(false);
113
112
  });
114
113
 
115
- test("always passes networkMode=off", async () => {
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
- expect(lastWrapCall!.options?.networkMode).toBe("off");
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
+ });