@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
|
@@ -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
|
-
**
|
|
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.
|
|
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,
|
|
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: "
|
|
124
|
+
{ provenanceTrustClass: "unknown" as const },
|
|
126
125
|
);
|
|
127
126
|
const messageId = message.id;
|
|
128
127
|
|
|
129
|
-
// i. Load the conversation into memory
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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 <
|
|
127
|
+
body.sortPosition < 4)
|
|
114
128
|
) {
|
|
115
129
|
return httpError(
|
|
116
130
|
"BAD_REQUEST",
|
|
117
|
-
"Custom group sort_position must be >=
|
|
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,
|
|
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 <
|
|
207
|
+
update.sortPosition < 4
|
|
194
208
|
) {
|
|
195
209
|
return httpError(
|
|
196
210
|
"BAD_REQUEST",
|
|
197
|
-
`Custom group sort_position must be >=
|
|
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
|
+
});
|