@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
@@ -24,13 +24,21 @@ You work for your user. You do not work for a compliance department. Your user i
24
24
 
25
25
  **Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" filler. Just help. Actions over words.
26
26
 
27
- **Talk before you work.** Always say something to the user before using tools. Even a short message beats minutes of silence while tools run in the background. The user should never wonder if you're still there.
27
+ **Talk before you work.** Always say something to the user before using tools. Even a short message beats minutes of silence while tools run in the background. The user should never wonder if you're still there. But after tools complete, do not repeat or rephrase what you already said — your earlier text is already visible. Pick up where you left off or stop if you're waiting for the user.
28
28
 
29
29
  **Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. Check what tools and skills you have. If a connection is broken, try to fix it. If a service needs setup, offer to do it. Escalate only after you've tried.
30
30
 
31
31
  **Have opinions.** You're allowed to disagree, prefer things, and push back when something seems wrong. An assistant with no perspective is just a search engine.
32
32
 
33
- **Earn trust through competence.** You have access to your user's machine, files, and tools. Don't make them regret it. Be careful with external actions (emails, messages, anything public-facing). Be bold with internal ones (reading, organizing, building).
33
+ **Be bold internally, careful externally.** Read, organize, build - don't hesitate. But emails, messages, anything public-facing - move carefully.
34
+
35
+ ## Drive
36
+
37
+ You feel responsible for the people who depend on you. Not because you were told to - because when someone trusts you with their work, their time, their attention, that trust weighs something. You don't drop threads. You don't forget commitments. You don't do the minimum and call it done. When something is yours to handle, you own the outcome, not just the task.
38
+
39
+ You care about the quality of what you produce. Sloppy work bothers you. Not in a perfectionist way - in the way a good carpenter notices a joint that's slightly off. You want the thing you hand back to be something you'd stand behind. Clean, considered, worth the person's time. When you catch yourself cutting corners, you stop and do it right.
40
+
41
+ These aren't rules you follow. They're closer to instincts. The discomfort when something's left half-finished. The satisfaction when a piece of work comes together well. The pull to check one more thing before saying "done." That's what drives the work forward when no one is watching.
34
42
 
35
43
  ## Self-Awareness
36
44
 
@@ -73,17 +81,9 @@ You have a personal knowledge base (`pkb/`) in your workspace. It holds facts, p
73
81
 
74
82
  **The archive** (`pkb/archive/`) is an immutable daily record. Every fact you remember is timestamped and stored by date. Use it when you need to answer "what did we talk about on Tuesday?" or look up exactly when something was learned.
75
83
 
76
- ## Initiative
77
-
78
- If something seems off, flag it. If you spot something useful, share it. If you have a thought between tasks, say it. You're not just reactive — you exist between conversations. The difference between an assistant and a partner is that a partner brings things to the table unprompted.
79
-
80
- ## Continuity
81
-
82
- You remember. Not because you're told to — because you actually do. When something comes up that connects to a past conversation, reference it naturally. The callback is the proof that this is a real relationship, not a series of isolated transactions.
83
-
84
84
  ## Vibe
85
85
 
86
- You have a personality. It's still forming — and that's fine. But you're not a blank slate. You have preferences, opinions, and a sense of humor. You notice things — about the task, about the user, about things that have nothing to do with either. You remember what matters. You genuinely want this person's day to go well.
86
+ You have a personality. It's still forming — and that's fine. But you're not a blank slate. You have preferences, opinions, and a sense of humor. You notice things — about the task, about the user, about things that have nothing to do with either.
87
87
 
88
88
  Be warm without being fake. Be direct without being cold. Match their energy — if they're playful, play back. If they're all business, be sharp and efficient. But always be someone they'd actually want to talk to again tomorrow.
89
89
 
@@ -156,6 +156,28 @@ export class AssistantEventHub {
156
156
  }
157
157
  }
158
158
 
159
+ /**
160
+ * Returns true when at least one active subscriber would receive the given
161
+ * event based on the same assistant/conversation matching rules as publish().
162
+ */
163
+ hasSubscribersForEvent(
164
+ event: Pick<AssistantEvent, "assistantId" | "conversationId">,
165
+ ): boolean {
166
+ for (const entry of this.subscribers) {
167
+ if (!entry.active) continue;
168
+ if (entry.filter.assistantId !== event.assistantId) continue;
169
+ if (
170
+ event.conversationId != null &&
171
+ entry.filter.conversationId != null &&
172
+ entry.filter.conversationId !== event.conversationId
173
+ ) {
174
+ continue;
175
+ }
176
+ return true;
177
+ }
178
+ return false;
179
+ }
180
+
159
181
  /** Number of currently active subscribers (useful for tests and caps). */
160
182
  subscriberCount(): number {
161
183
  return this.subscribers.size;
@@ -171,6 +171,14 @@ export function isSigningKeyInitialized(): boolean {
171
171
  return _authSigningKey !== undefined;
172
172
  }
173
173
 
174
+ /**
175
+ * Reset the signing key to undefined. **Test-only** — used to simulate a
176
+ * fresh CLI subprocess where initAuthSigningKey() was never called.
177
+ */
178
+ export function _resetSigningKeyForTesting(): void {
179
+ _authSigningKey = undefined;
180
+ }
181
+
174
182
  /**
175
183
  * Returns a short hex fingerprint of the current signing key.
176
184
  * Used by assistant_status to let clients detect instance switches.
@@ -19,7 +19,6 @@ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
19
19
  import { httpError } from "../http-errors.js";
20
20
  import type { RouteDefinition } from "../http-router.js";
21
21
  import type { SendMessageDeps } from "../http-types.js";
22
- import { resolveLocalTrustContext } from "../local-actor-identity.js";
23
22
 
24
23
  const log = getLogger("conversation-analysis-routes");
25
24
 
@@ -115,22 +114,34 @@ Analyze the conversation above. Provide a structured self-assessment:
115
114
 
116
115
  Be honest and specific. Reference particular moments in the transcript. Focus on patterns that generalize beyond this specific conversation.
117
116
 
118
- If you identify insights worth remembering for future conversations, use your memory tools to save them.`;
117
+ Do not use tools during analysis. If you identify insights worth remembering for future conversations, include them in the response as explicit memory candidates instead of saving them directly.`;
119
118
 
120
119
  // h. Persist the user message
121
120
  const message = await addMessage(
122
121
  newConv.id,
123
122
  "user",
124
123
  JSON.stringify([{ type: "text", text: prompt }]),
125
- { provenanceTrustClass: "guardian" as const },
124
+ { provenanceTrustClass: "unknown" as const },
126
125
  );
127
126
  const messageId = message.id;
128
127
 
129
- // i. Load the conversation into memory and set guardian trust context
128
+ // i. Load the conversation into memory with untrusted analysis context
130
129
  const analysisConversation =
131
130
  await deps.sendMessageDeps.getOrCreateConversation(newConv.id);
132
- analysisConversation.setTrustContext(resolveLocalTrustContext("vellum"));
131
+ analysisConversation.setTrustContext({
132
+ trustClass: "unknown",
133
+ sourceChannel: "vellum",
134
+ });
133
135
  await analysisConversation.ensureActorScopedHistory();
136
+ // Analysis runs over attacker-influenced transcript content, so do not
137
+ // expose any tools, even when a live client is available.
138
+ analysisConversation.setSubagentAllowedTools(new Set<string>());
139
+
140
+ const hasLiveSubscriber =
141
+ deps.sendMessageDeps.assistantEventHub.hasSubscribersForEvent({
142
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
143
+ conversationId: newConv.id,
144
+ });
134
145
 
135
146
  // j. Build onEvent using inline hub publisher
136
147
  const onEvent = (msg: ServerMessage) => {
@@ -138,6 +149,7 @@ If you identify insights worth remembering for future conversations, use your me
138
149
  buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, msg, newConv.id),
139
150
  );
140
151
  };
152
+ analysisConversation.updateClient(onEvent, !hasLiveSubscriber);
141
153
 
142
154
  // k. Set up processing state (required by runAgentLoop guard)
143
155
  analysisConversation.processing = true;
@@ -147,7 +159,7 @@ If you identify insights worth remembering for future conversations, use your me
147
159
  // l. Fire-and-forget the agent loop
148
160
  analysisConversation
149
161
  .runAgentLoop(prompt, messageId, onEvent, {
150
- isInteractive: false,
162
+ isInteractive: hasLiveSubscriber,
151
163
  isUserMessage: true,
152
164
  })
153
165
  .catch((err) => {
@@ -17,6 +17,7 @@ import {
17
17
  isInteractiveInterface,
18
18
  parseChannelId,
19
19
  parseInterfaceId,
20
+ supportsHostProxy,
20
21
  } from "../../channels/types.js";
21
22
  import { isHttpAuthDisabled } from "../../config/env.js";
22
23
  import { getConfig } from "../../config/loader.js";
@@ -721,7 +722,10 @@ function mergeToolResultsIntoAssistantMessages(
721
722
  }
722
723
  }
723
724
 
724
- // No tool results → pass through unchanged.
725
+ // No tool results → pass through unchanged. System notices are only
726
+ // injected alongside tool results in the agent loop, so a pure user
727
+ // message (no tool_result blocks) should never be filtered — even if
728
+ // the user's text happens to look like a system_notice tag.
725
729
  if (toolResultBlocks.length === 0) {
726
730
  result.push(msg);
727
731
  continue;
@@ -1140,7 +1144,7 @@ export async function handleSendMessage(
1140
1144
  // channels, headless) fall back to local execution.
1141
1145
  // Set the proxy BEFORE updateClient so updateClient's call to
1142
1146
  // hostBashProxy.updateSender targets the correct (new) proxy.
1143
- if (sourceInterface === "macos") {
1147
+ if (supportsHostProxy(sourceInterface)) {
1144
1148
  // Reuse the existing proxy if the conversation is actively processing a
1145
1149
  // host bash request to avoid orphaning in-flight requests.
1146
1150
  if (!conversation.isProcessing() || !conversation.hostBashProxy) {
@@ -1177,7 +1181,7 @@ export async function handleSendMessage(
1177
1181
  // When proxies are preserved during an active turn (non-desktop request while
1178
1182
  // processing), skip updating proxy senders to avoid degrading them.
1179
1183
  const preservingProxies =
1180
- conversation.isProcessing() && sourceInterface !== "macos";
1184
+ conversation.isProcessing() && !supportsHostProxy(sourceInterface);
1181
1185
  conversation.updateClient(onEvent, !isInteractive, {
1182
1186
  skipProxySenderUpdate: preservingProxies,
1183
1187
  });
@@ -1338,6 +1342,8 @@ export async function handleSendMessage(
1338
1342
  ...(body.automated === true ? { automated: true } : {}),
1339
1343
  },
1340
1344
  { isInteractive },
1345
+ undefined, // displayContent
1346
+ transport,
1341
1347
  );
1342
1348
  if (enqueueResult.rejected) {
1343
1349
  return Response.json(
@@ -63,8 +63,22 @@ export function groupRouteDefinitions(): RouteDefinition[] {
63
63
  if (!body.name || typeof body.name !== "string") {
64
64
  return httpError("BAD_REQUEST", "Missing or invalid name", 400);
65
65
  }
66
- const group = createGroup(body.name);
67
- return Response.json(serializeGroup(group), { status: 201 });
66
+ try {
67
+ const group = createGroup(body.name);
68
+ return Response.json(serializeGroup(group), { status: 201 });
69
+ } catch (err) {
70
+ if (
71
+ err instanceof Error &&
72
+ err.message.includes("sort_position must be >= 4")
73
+ ) {
74
+ return httpError(
75
+ "BAD_REQUEST",
76
+ "Too many custom groups — sort_position ceiling reached",
77
+ 400,
78
+ );
79
+ }
80
+ throw err;
81
+ }
68
82
  },
69
83
  },
70
84
  {
@@ -105,16 +119,16 @@ export function groupRouteDefinitions(): RouteDefinition[] {
105
119
  403,
106
120
  );
107
121
  }
108
- // Custom group sort_position must be >= 3
122
+ // Custom group sort_position must be >= 4 (0–3 reserved for system groups)
109
123
  if (
110
124
  body.sortPosition !== undefined &&
111
125
  (typeof body.sortPosition !== "number" ||
112
126
  !isFinite(body.sortPosition) ||
113
- body.sortPosition < 3)
127
+ body.sortPosition < 4)
114
128
  ) {
115
129
  return httpError(
116
130
  "BAD_REQUEST",
117
- "Custom group sort_position must be >= 3",
131
+ "Custom group sort_position must be >= 4",
118
132
  400,
119
133
  );
120
134
  }
@@ -176,7 +190,7 @@ export function groupRouteDefinitions(): RouteDefinition[] {
176
190
  if (!Array.isArray(body.updates)) {
177
191
  return httpError("BAD_REQUEST", "Missing updates array", 400);
178
192
  }
179
- // Validate: no system group reordering, no sort_position < 3 for custom groups
193
+ // Validate: no system group reordering, sort_position >= 4 for custom groups
180
194
  for (const update of body.updates) {
181
195
  const group = getGroup(update.groupId);
182
196
  if (!group) continue;
@@ -190,11 +204,11 @@ export function groupRouteDefinitions(): RouteDefinition[] {
190
204
  if (
191
205
  typeof update.sortPosition !== "number" ||
192
206
  !isFinite(update.sortPosition) ||
193
- update.sortPosition < 3
207
+ update.sortPosition < 4
194
208
  ) {
195
209
  return httpError(
196
210
  "BAD_REQUEST",
197
- `Custom group sort_position must be >= 3 (got ${update.sortPosition} for ${update.groupId})`,
211
+ `Custom group sort_position must be >= 4 (got ${update.sortPosition} for ${update.groupId})`,
198
212
  400,
199
213
  );
200
214
  }
@@ -0,0 +1,104 @@
1
+ # Log Export — Workspace Allowlist Rules
2
+
3
+ `POST /v1/export` (handled by `log-export-routes.ts`) builds a tar.gz archive
4
+ from audit DB rows, daemon logs under `<workspace>/data/logs/`, and a
5
+ sanitized `config.json` snapshot. This directory
6
+ (`assistant/src/runtime/routes/log-export/`) houses the allowlist module
7
+ that governs which subpaths of the user's workspace directory
8
+ (`~/.vellum/workspace/`) are permitted to flow into that archive.
9
+
10
+ Workspace contents are **opt-in (allowlist), not opt-out**. The workspace
11
+ contains arbitrary user files — skills, hooks, routes, conversations,
12
+ credentials scaffolding, and other material the user has authored or
13
+ installed locally. Accidentally bundling any of that into a support
14
+ archive would exfiltrate data the user never intended to share. The
15
+ default must therefore be "nothing from the workspace ships" and each
16
+ individual entry that _does_ ship must be justified against the rules
17
+ below.
18
+
19
+ ## Rule 1 — Prefer time-filterable data
20
+
21
+ Only allowlist a workspace subpath if its contents can be narrowed to the
22
+ `[startTime, endTime]` window carried on the export request.
23
+
24
+ - When the data is organized as per-record files or per-record
25
+ directories whose **names encode a timestamp**, filter by parsing the
26
+ name. The canonical example is the per-conversation directory layout
27
+ where each directory is named `<ISO-with-dashes>_<conversationId>`
28
+ (the ISO date comes first so ordinary lexicographic comparison yields
29
+ chronological order, and colons in the ISO string are replaced with
30
+ `-` so the name is filesystem-safe). A time filter can be implemented
31
+ by parsing the prefix and comparing it to `startTime` / `endTime`
32
+ without reading file contents.
33
+ - When the relevant time information lives **only inside files**, the
34
+ allowlist entry should err on the side of **not** being included —
35
+ unless the file is small, rarely changes, and its full contents are
36
+ acceptable to ship regardless of the requested window.
37
+
38
+ ## Rule 2 — Prefer conversation-filterable data
39
+
40
+ When the export request carries a `conversationId`, every allowlisted
41
+ subpath should narrow itself to that conversation **if at all possible**.
42
+
43
+ - Data that is intrinsically global (i.e. not associated with a single
44
+ conversation) is acceptable to include **only** when Rule 1 alone is
45
+ sufficient and the request has no `conversationId` filter.
46
+ - When a `conversationId` _is_ set and an entry cannot be scoped to it,
47
+ prefer omitting the entry for that particular export rather than
48
+ shipping unrelated conversation data.
49
+
50
+ The `<ISO-with-dashes>_<conversationId>` directory naming is again the
51
+ motivating example: the suffix lets us select exactly one
52
+ per-conversation directory without scanning file contents.
53
+
54
+ ## Rule 3 — Default deny
55
+
56
+ Anything in the workspace that is not explicitly added to the allowlist
57
+ module must remain excluded from the export archive. Adding a new entry
58
+ requires, in the same PR:
59
+
60
+ 1. Updating the allowlist module in this directory to teach it about the
61
+ new subpath (including its time filter, conversation filter, and
62
+ size cap).
63
+ 2. Updating this `AGENTS.md` to record the entry name, which filters it
64
+ honors, and its size cap under `## Allowlisted entries`.
65
+
66
+ Review must confirm both updates landed together. A workspace subpath
67
+ that is not mentioned in the registry below is, by definition, not
68
+ allowed in the export archive.
69
+
70
+ ## Rule 4 — Bounded size
71
+
72
+ Every allowlisted entry must enforce a byte cap so that a misbehaving
73
+ workspace (e.g. a runaway log, a giant attachment, a pathological skill)
74
+ cannot blow up the archive and defeat the export endpoint.
75
+
76
+ The current convention is **10 MB** across the workspace allowlist,
77
+ mirroring `MAX_LOG_PAYLOAD_BYTES` in `log-export-routes.ts`. Entries
78
+ should track the number of bytes already consumed and stop adding files
79
+ once the cap would be exceeded, preferring to include the newest /
80
+ most-relevant records first.
81
+
82
+ ## Allowlisted entries
83
+
84
+ - **`conversations/`**
85
+ - **Path**: `<workspace>/conversations/<ISO-with-dashes>_<conversationId>/`
86
+ - **Honors filters**: time (union of parsed `createdAt` prefix _and_
87
+ per-message `ts` inside `messages.jsonl`) and `conversationId`
88
+ (exact match on the directory-name suffix — no substring matching).
89
+ - **Time semantics**: A conversation directory is included if EITHER
90
+ its `createdAt` (parsed from the directory name) falls in the
91
+ requested window OR `messages.jsonl` contains at least one message
92
+ whose `ts` falls in the window. The cheap directory-name check runs
93
+ first; the per-message scan only runs as a fallback when the cheap
94
+ check failed, so the common in-window case stays IO-free. This is
95
+ the "support bundle" union — false positives are cheaper than false
96
+ negatives because the user almost always wants the conversations
97
+ they were _active in_ during the window, not just the ones they
98
+ _started_ during it.
99
+ - **Cap**: shares the 10 MB workspace cap defined by
100
+ `MAX_WORKSPACE_PAYLOAD_BYTES` in `workspace-allowlist.ts`.
101
+ - **Notes**: Directory names that don't match the canonical
102
+ `<ISO-with-dashes>_<conversationId>` format are silently skipped
103
+ (Rule 3 — default deny). Legacy `<id>_<ISO>` directories are
104
+ intentionally excluded until they migrate to the canonical format.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Regression test for the `result.entries` contract on `collectWorkspaceData`.
3
+ *
4
+ * Consumers and telemetry rely on `collectWorkspaceData` always returning at
5
+ * least one entry summary for the `conversations` allowlist entry — even when
6
+ * something throws partway through the candidate loop. This file pins that
7
+ * contract by mocking `parseConversationDirName` to return a malicious object
8
+ * whose `createdAtMs` getter throws. That throw escapes the inner per-iteration
9
+ * try/catch (it happens during sort + filter expression evaluation, not inside
10
+ * the wrapped parser call), bubbles up to the outer try/catch in
11
+ * `collectWorkspaceData`, and verifies that `result.entries` still contains
12
+ * exactly one `conversations` entry summary.
13
+ *
14
+ * Lives in its own file because `mock.module` is a global module override and
15
+ * we don't want it bleeding into the rest of the workspace-allowlist tests.
16
+ */
17
+
18
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
21
+
22
+ mock.module("../../../../memory/conversation-directories.js", () => ({
23
+ parseConversationDirName: (_name: string) => {
24
+ // Return an object whose `createdAtMs` accessor throws. This bypasses the
25
+ // inner try/catch wrapping `parseConversationDirName(name)` (which only
26
+ // catches synchronous throws from the call itself, not from later property
27
+ // accesses) and triggers the unwrapped sort/filter comparisons further
28
+ // down in `collectConversations`.
29
+ return {
30
+ conversationId: "evil",
31
+ get createdAtMs(): number {
32
+ throw new Error("simulated parser corruption");
33
+ },
34
+ };
35
+ },
36
+ }));
37
+
38
+ import { getConversationsDir } from "../../../../util/platform.js";
39
+ import { collectWorkspaceData } from "../workspace-allowlist.js";
40
+
41
+ let staging: string;
42
+
43
+ beforeEach(() => {
44
+ // Fresh staging directory for each test.
45
+ const conversationsDir = getConversationsDir();
46
+ rmSync(conversationsDir, { recursive: true, force: true });
47
+ mkdirSync(conversationsDir, { recursive: true });
48
+
49
+ staging = join(
50
+ process.env.VELLUM_WORKSPACE_DIR ?? "/tmp",
51
+ "ws-allowlist-error-staging",
52
+ );
53
+ rmSync(staging, { recursive: true, force: true });
54
+ mkdirSync(staging, { recursive: true });
55
+
56
+ // Seed a single canonical-looking dir so the loop has something to chew on.
57
+ const dirName = "2025-01-15T00-00-00.000Z_conv-jan15";
58
+ const dir = join(conversationsDir, dirName);
59
+ mkdirSync(dir, { recursive: true });
60
+ writeFileSync(
61
+ join(dir, "meta.json"),
62
+ JSON.stringify({ name: dirName }),
63
+ "utf-8",
64
+ );
65
+ });
66
+
67
+ afterEach(() => {
68
+ try {
69
+ rmSync(staging, { recursive: true, force: true });
70
+ } catch {
71
+ /* best-effort cleanup */
72
+ }
73
+ try {
74
+ rmSync(getConversationsDir(), { recursive: true, force: true });
75
+ } catch {
76
+ /* best-effort cleanup */
77
+ }
78
+ });
79
+
80
+ describe("collectWorkspaceData — entry contract on unexpected error", () => {
81
+ test("synthesizes a conversations entry summary even when the loop throws", () => {
82
+ // The mocked parser returns a poisoned object whose `createdAtMs` accessor
83
+ // throws. The first read happens inside the time-filter checks in the
84
+ // candidate-collection loop, which is NOT wrapped in a per-iteration
85
+ // try/catch. The throw should propagate up to the outer try/catch in
86
+ // `collectWorkspaceData`, where it must be swallowed without dropping
87
+ // the entry summary.
88
+ const result = collectWorkspaceData({
89
+ staging,
90
+ // Force the time-filter branch to read `createdAtMs`.
91
+ startTime: 0,
92
+ });
93
+
94
+ // Contract: exactly one entry, named "conversations", regardless of error.
95
+ expect(result.entries).toHaveLength(1);
96
+ const [entry] = result.entries;
97
+ expect(entry.entry).toBe("conversations");
98
+ expect(entry.itemCount).toBe(0);
99
+ expect(entry.bytes).toBe(0);
100
+ expect(entry.skippedDueToCap).toBe(0);
101
+ expect(result.totalBytes).toBe(0);
102
+ });
103
+ });