@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
|
@@ -45,7 +45,10 @@ mock.module("../security/secure-keys.js", () => ({
|
|
|
45
45
|
|
|
46
46
|
// Mock global fetch
|
|
47
47
|
const _originalFetch = globalThis.fetch;
|
|
48
|
-
const mockFetch = async (
|
|
48
|
+
const mockFetch = async (
|
|
49
|
+
input: string | URL | Request,
|
|
50
|
+
_init?: RequestInit,
|
|
51
|
+
) => {
|
|
49
52
|
const url =
|
|
50
53
|
typeof input === "string"
|
|
51
54
|
? input
|
|
@@ -57,14 +60,6 @@ const mockFetch = async (input: string | URL | Request, init?: RequestInit) => {
|
|
|
57
60
|
return new Response("Not Found", { status: 404 });
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
// Verify the Authorization header never leaks secrets into the request path
|
|
61
|
-
const authHeader = init?.headers
|
|
62
|
-
? (init.headers as Record<string, string>)["Authorization"]
|
|
63
|
-
: undefined;
|
|
64
|
-
if (authHeader && !authHeader.startsWith("Api-Key ")) {
|
|
65
|
-
throw new Error("Unexpected auth header format");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
63
|
return new Response(JSON.stringify(entry.body), {
|
|
69
64
|
status: entry.status,
|
|
70
65
|
headers: { "Content-Type": "application/json" },
|
|
@@ -266,17 +261,17 @@ describe("fetchManagedCatalog", () => {
|
|
|
266
261
|
expect(descriptor.handle).toBe("platform_oauth:conn_minimal");
|
|
267
262
|
});
|
|
268
263
|
|
|
269
|
-
test("error messages never contain
|
|
264
|
+
test("error messages never contain sensitive details", async () => {
|
|
270
265
|
mockPlatformBaseUrl = "https://platform.example.com";
|
|
271
266
|
mockAssistantApiKey = "sk-super-secret-key-12345";
|
|
272
267
|
mockPlatformAssistantId = "ast-uuid-1234";
|
|
273
268
|
|
|
274
|
-
// Simulate a network error
|
|
269
|
+
// Simulate a network error whose message contains sensitive data
|
|
275
270
|
const savedFetch = globalThis.fetch;
|
|
276
271
|
const errorFetch: typeof fetch = Object.assign(
|
|
277
272
|
async () => {
|
|
278
273
|
throw new Error(
|
|
279
|
-
"Connect failed to https://platform.example.com/v1/assistants/ast-uuid-1234/oauth/managed/catalog/ with
|
|
274
|
+
"Connect failed to https://platform.example.com/v1/assistants/ast-uuid-1234/oauth/managed/catalog/ with Bearer sk-super-secret-key-12345",
|
|
280
275
|
);
|
|
281
276
|
},
|
|
282
277
|
{ preconnect: savedFetch.preconnect },
|
|
@@ -287,9 +282,12 @@ describe("fetchManagedCatalog", () => {
|
|
|
287
282
|
const result = await fetchManagedCatalog();
|
|
288
283
|
expect(result.ok).toBe(false);
|
|
289
284
|
expect(result.error).toBeDefined();
|
|
290
|
-
//
|
|
285
|
+
// Raw error message (with URL, API key, etc.) must not leak
|
|
291
286
|
expect(result.error).not.toContain("sk-super-secret-key-12345");
|
|
292
|
-
expect(result.error).toContain("
|
|
287
|
+
expect(result.error).not.toContain("platform.example.com");
|
|
288
|
+
expect(result.error).not.toContain("Connect failed");
|
|
289
|
+
// Should only contain the error class name
|
|
290
|
+
expect(result.error).toContain("Error");
|
|
293
291
|
} finally {
|
|
294
292
|
globalThis.fetch = savedFetch;
|
|
295
293
|
}
|
|
@@ -14,7 +14,7 @@ const CANONICAL_TABS = [
|
|
|
14
14
|
"Sounds",
|
|
15
15
|
"Permissions & Privacy",
|
|
16
16
|
"Billing",
|
|
17
|
-
"
|
|
17
|
+
"Archive",
|
|
18
18
|
"Schedules",
|
|
19
19
|
"Developer",
|
|
20
20
|
];
|
|
@@ -85,4 +85,17 @@ describe("navigate-settings-tab", () => {
|
|
|
85
85
|
expect(result.isError).toBe(false);
|
|
86
86
|
expect(result.content).toContain("Developer");
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test("normalizes legacy 'Archived Conversations' alias to 'Archive'", async () => {
|
|
90
|
+
const messages: unknown[] = [];
|
|
91
|
+
const result = await run(
|
|
92
|
+
{ tab: "Archived Conversations" },
|
|
93
|
+
makeContext((msg) => messages.push(msg)),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(result.isError).toBe(false);
|
|
97
|
+
expect(result.content).toContain("Archive");
|
|
98
|
+
expect(messages).toHaveLength(1);
|
|
99
|
+
expect(messages[0]).toEqual({ type: "navigate_settings", tab: "Archive" });
|
|
100
|
+
});
|
|
88
101
|
});
|
|
@@ -583,4 +583,69 @@ describe("notification broadcaster", () => {
|
|
|
583
583
|
expect(vellumCall).toBeDefined();
|
|
584
584
|
expect(vellumCall!.options?.bindingContext).toBeUndefined();
|
|
585
585
|
});
|
|
586
|
+
|
|
587
|
+
// ── conversationMetadata propagation ──────────────────────────────
|
|
588
|
+
|
|
589
|
+
test("onConversationCreated includes groupId and source from conversationMetadata", async () => {
|
|
590
|
+
const vellumAdapter = new MockAdapter("vellum");
|
|
591
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
592
|
+
const createdCalls: ConversationCreatedInfo[] = [];
|
|
593
|
+
broadcaster.setOnConversationCreated((info) => createdCalls.push(info));
|
|
594
|
+
|
|
595
|
+
const signal = makeSignal({
|
|
596
|
+
sourceEventName: "schedule.complete",
|
|
597
|
+
conversationMetadata: {
|
|
598
|
+
groupId: "system:scheduled",
|
|
599
|
+
source: "schedule",
|
|
600
|
+
scheduleJobId: "job-abc-123",
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
const decision = makeDecision();
|
|
604
|
+
|
|
605
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
606
|
+
|
|
607
|
+
expect(createdCalls).toHaveLength(1);
|
|
608
|
+
expect(createdCalls[0].groupId).toBe("system:scheduled");
|
|
609
|
+
expect(createdCalls[0].source).toBe("schedule");
|
|
610
|
+
expect(createdCalls[0].sourceEventName).toBe("schedule.complete");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("onConversationCreated omits groupId and source when conversationMetadata is absent", async () => {
|
|
614
|
+
const vellumAdapter = new MockAdapter("vellum");
|
|
615
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
616
|
+
const createdCalls: ConversationCreatedInfo[] = [];
|
|
617
|
+
broadcaster.setOnConversationCreated((info) => createdCalls.push(info));
|
|
618
|
+
|
|
619
|
+
const signal = makeSignal(); // no conversationMetadata
|
|
620
|
+
const decision = makeDecision();
|
|
621
|
+
|
|
622
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
623
|
+
|
|
624
|
+
expect(createdCalls).toHaveLength(1);
|
|
625
|
+
expect(createdCalls[0].groupId).toBeUndefined();
|
|
626
|
+
expect(createdCalls[0].source).toBeUndefined();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("per-dispatch callback receives conversationMetadata fields", async () => {
|
|
630
|
+
const vellumAdapter = new MockAdapter("vellum");
|
|
631
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
632
|
+
const dispatchCalls: ConversationCreatedInfo[] = [];
|
|
633
|
+
|
|
634
|
+
const signal = makeSignal({
|
|
635
|
+
sourceEventName: "schedule.complete",
|
|
636
|
+
conversationMetadata: {
|
|
637
|
+
groupId: "system:scheduled",
|
|
638
|
+
source: "schedule",
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
const decision = makeDecision();
|
|
642
|
+
|
|
643
|
+
await broadcaster.broadcastDecision(signal, decision, {
|
|
644
|
+
onConversationCreated: (info) => dispatchCalls.push(info),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
expect(dispatchCalls).toHaveLength(1);
|
|
648
|
+
expect(dispatchCalls[0].groupId).toBe("system:scheduled");
|
|
649
|
+
expect(dispatchCalls[0].source).toBe("schedule");
|
|
650
|
+
});
|
|
586
651
|
});
|
|
@@ -104,11 +104,12 @@ describe("onboarding template contracts", () => {
|
|
|
104
104
|
expect(bootstrap).toContain("Check my email");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
test("
|
|
107
|
+
test("keeps momentum by chaining off the first task", () => {
|
|
108
108
|
const lower = bootstrap.toLowerCase();
|
|
109
|
-
expect(lower).toContain("
|
|
110
|
-
expect(lower).toContain("
|
|
111
|
-
expect(lower).toContain("
|
|
109
|
+
expect(lower).toContain("keep the momentum");
|
|
110
|
+
expect(lower).toContain("don't pivot to setup");
|
|
111
|
+
expect(lower).toContain("chain off the task");
|
|
112
|
+
expect(lower).toContain("while we're at it");
|
|
112
113
|
});
|
|
113
114
|
});
|
|
114
115
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import { readAutoinjectList } from "../daemon/conversation-runtime-assembly.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Setup / Teardown
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
let pkbDir: string;
|
|
13
|
+
const dirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
pkbDir = join(
|
|
17
|
+
tmpdir(),
|
|
18
|
+
`vellum-pkb-autoinject-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
19
|
+
);
|
|
20
|
+
mkdirSync(pkbDir, { recursive: true });
|
|
21
|
+
dirs.push(pkbDir);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const dir of dirs.splice(0)) {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Tests
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
describe("readAutoinjectList", () => {
|
|
35
|
+
test("returns null when _autoinject.md does not exist", () => {
|
|
36
|
+
expect(readAutoinjectList(pkbDir)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns empty array when _autoinject.md is empty", () => {
|
|
40
|
+
writeFileSync(join(pkbDir, "_autoinject.md"), "", "utf-8");
|
|
41
|
+
expect(readAutoinjectList(pkbDir)).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns empty array when _autoinject.md contains only comments", () => {
|
|
45
|
+
writeFileSync(
|
|
46
|
+
join(pkbDir, "_autoinject.md"),
|
|
47
|
+
"_ This is a comment\n_ Another comment\n",
|
|
48
|
+
"utf-8",
|
|
49
|
+
);
|
|
50
|
+
expect(readAutoinjectList(pkbDir)).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("parses standard default content", () => {
|
|
54
|
+
writeFileSync(
|
|
55
|
+
join(pkbDir, "_autoinject.md"),
|
|
56
|
+
"_ comment\n\nINDEX.md\nessentials.md\nthreads.md\nbuffer.md\n",
|
|
57
|
+
"utf-8",
|
|
58
|
+
);
|
|
59
|
+
expect(readAutoinjectList(pkbDir)).toEqual([
|
|
60
|
+
"INDEX.md",
|
|
61
|
+
"essentials.md",
|
|
62
|
+
"threads.md",
|
|
63
|
+
"buffer.md",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("parses custom entries", () => {
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(pkbDir, "_autoinject.md"),
|
|
70
|
+
"INDEX.md\ncustom-topic.md\n",
|
|
71
|
+
"utf-8",
|
|
72
|
+
);
|
|
73
|
+
expect(readAutoinjectList(pkbDir)).toEqual([
|
|
74
|
+
"INDEX.md",
|
|
75
|
+
"custom-topic.md",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("strips blank lines and whitespace", () => {
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(pkbDir, "_autoinject.md"),
|
|
82
|
+
"INDEX.md\n\n essentials.md \n\n",
|
|
83
|
+
"utf-8",
|
|
84
|
+
);
|
|
85
|
+
expect(readAutoinjectList(pkbDir)).toEqual(["INDEX.md", "essentials.md"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("strips comment lines mixed with filenames", () => {
|
|
89
|
+
writeFileSync(
|
|
90
|
+
join(pkbDir, "_autoinject.md"),
|
|
91
|
+
"_ Always loaded files\nINDEX.md\n_ Core facts\nessentials.md\n",
|
|
92
|
+
"utf-8",
|
|
93
|
+
);
|
|
94
|
+
expect(readAutoinjectList(pkbDir)).toEqual(["INDEX.md", "essentials.md"]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -416,7 +416,6 @@ describe("requireFreshApproval: persistent decisions disabled", () => {
|
|
|
416
416
|
_allowlistOptions: unknown[],
|
|
417
417
|
_scopeOptions: unknown[],
|
|
418
418
|
_previewDiff: unknown,
|
|
419
|
-
_sandboxed: boolean | undefined,
|
|
420
419
|
_conversationId: string,
|
|
421
420
|
_executionTarget: string,
|
|
422
421
|
persistentDecisionsAllowed: boolean,
|
|
@@ -467,7 +466,6 @@ describe("requireFreshApproval: persistent decisions disabled", () => {
|
|
|
467
466
|
_allowlistOptions: unknown[],
|
|
468
467
|
_scopeOptions: unknown[],
|
|
469
468
|
_previewDiff: unknown,
|
|
470
|
-
_sandboxed: boolean | undefined,
|
|
471
469
|
_conversationId: string,
|
|
472
470
|
_executionTarget: string,
|
|
473
471
|
persistentDecisionsAllowed: boolean,
|
|
@@ -10,22 +10,6 @@ mock.module("node:child_process", () => ({
|
|
|
10
10
|
execSync: execSyncMock,
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
-
// Mock config loader — return a config with sandbox settings
|
|
14
|
-
let mockSandboxConfig: {
|
|
15
|
-
enabled: boolean;
|
|
16
|
-
} = {
|
|
17
|
-
enabled: true,
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
mock.module("../config/loader.js", () => ({
|
|
21
|
-
getConfig: () => ({
|
|
22
|
-
ui: {},
|
|
23
|
-
|
|
24
|
-
sandbox: mockSandboxConfig,
|
|
25
|
-
}),
|
|
26
|
-
loadRawConfig: () => ({}),
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
13
|
mock.module("../util/logger.js", () => ({
|
|
30
14
|
getLogger: () => ({
|
|
31
15
|
error: () => {},
|
|
@@ -49,9 +33,6 @@ function setPlatform(platform: string): void {
|
|
|
49
33
|
|
|
50
34
|
beforeEach(() => {
|
|
51
35
|
execSyncMock.mockReset();
|
|
52
|
-
mockSandboxConfig = {
|
|
53
|
-
enabled: true,
|
|
54
|
-
};
|
|
55
36
|
// Default: all commands succeed.
|
|
56
37
|
execSyncMock.mockImplementation(() => undefined);
|
|
57
38
|
});
|
|
@@ -62,26 +43,14 @@ afterEach(() => {
|
|
|
62
43
|
});
|
|
63
44
|
|
|
64
45
|
describe("runSandboxDiagnostics — config reporting", () => {
|
|
65
|
-
test("reports sandbox
|
|
66
|
-
const result = runSandboxDiagnostics();
|
|
67
|
-
expect(result.config.enabled).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("reports sandbox disabled state", () => {
|
|
71
|
-
mockSandboxConfig.enabled = false;
|
|
46
|
+
test("reports sandbox disabled (sandbox removed)", () => {
|
|
72
47
|
const result = runSandboxDiagnostics();
|
|
73
48
|
expect(result.config.enabled).toBe(false);
|
|
74
49
|
});
|
|
75
50
|
});
|
|
76
51
|
|
|
77
52
|
describe("runSandboxDiagnostics — active backend reason", () => {
|
|
78
|
-
test("explains native backend selection", () => {
|
|
79
|
-
const result = runSandboxDiagnostics();
|
|
80
|
-
expect(result.activeBackendReason).toContain("Native backend");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
53
|
test("explains when sandbox is disabled", () => {
|
|
84
|
-
mockSandboxConfig.enabled = false;
|
|
85
54
|
const result = runSandboxDiagnostics();
|
|
86
55
|
expect(result.activeBackendReason).toContain("disabled");
|
|
87
56
|
});
|
|
@@ -2,7 +2,7 @@ import * as realChildProcess from "node:child_process";
|
|
|
2
2
|
import * as realFs from "node:fs";
|
|
3
3
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
4
4
|
|
|
5
|
-
import type { SandboxConfig } from "../
|
|
5
|
+
import type { SandboxConfig } from "../tools/terminal/sandbox.js";
|
|
6
6
|
|
|
7
7
|
let platform = "linux";
|
|
8
8
|
|
|
@@ -65,13 +65,13 @@ mock.module("../tools/network/script-proxy/index.js", () => ({
|
|
|
65
65
|
|
|
66
66
|
// ── Imports (after mocks) ───────────────────────────────────────────────────
|
|
67
67
|
|
|
68
|
-
import type { SandboxConfig } from "../config/schema.js";
|
|
69
68
|
import { parse } from "../tools/terminal/parser.js";
|
|
70
69
|
import {
|
|
71
70
|
ALWAYS_INJECTED_ENV_VARS,
|
|
72
71
|
buildSanitizedEnv,
|
|
73
72
|
SAFE_ENV_VARS,
|
|
74
73
|
} from "../tools/terminal/safe-env.js";
|
|
74
|
+
import type { SandboxConfig } from "../tools/terminal/sandbox.js";
|
|
75
75
|
import { wrapCommand } from "../tools/terminal/sandbox.js";
|
|
76
76
|
import { ToolError } from "../util/errors.js";
|
|
77
77
|
|
|
@@ -455,10 +455,7 @@ describe("buildSanitizedEnv", () => {
|
|
|
455
455
|
test("result is a plain object with no prototype-inherited secrets", () => {
|
|
456
456
|
const env = buildSanitizedEnv();
|
|
457
457
|
const keys = Object.keys(env);
|
|
458
|
-
const safeKeys: string[] = [
|
|
459
|
-
...SAFE_ENV_VARS,
|
|
460
|
-
...ALWAYS_INJECTED_ENV_VARS,
|
|
461
|
-
];
|
|
458
|
+
const safeKeys: string[] = [...SAFE_ENV_VARS, ...ALWAYS_INJECTED_ENV_VARS];
|
|
462
459
|
for (const key of keys) {
|
|
463
460
|
expect(safeKeys).toContain(key);
|
|
464
461
|
}
|
|
@@ -25,11 +25,25 @@ process.env.VELLUM_WORKSPACE_DIR = testDir;
|
|
|
25
25
|
process.env.VELLUM_PLATFORM_URL = "https://test-platform.vellum.ai";
|
|
26
26
|
process.exitCode = 0;
|
|
27
27
|
|
|
28
|
+
// Prevent tests from routing credential writes through the real CES
|
|
29
|
+
// (Credential Execution Service). Without this, setSecureKeyAsync() in
|
|
30
|
+
// containerized environments writes to the live credential store.
|
|
31
|
+
const savedIsContainerized = process.env.IS_CONTAINERIZED;
|
|
32
|
+
const savedCesCredentialUrl = process.env.CES_CREDENTIAL_URL;
|
|
33
|
+
delete process.env.IS_CONTAINERIZED;
|
|
34
|
+
delete process.env.CES_CREDENTIAL_URL;
|
|
35
|
+
|
|
28
36
|
afterAll(() => {
|
|
29
37
|
resetDb();
|
|
30
38
|
process.exitCode = 0;
|
|
31
39
|
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
32
40
|
delete process.env.VELLUM_PLATFORM_URL;
|
|
41
|
+
if (savedIsContainerized !== undefined) {
|
|
42
|
+
process.env.IS_CONTAINERIZED = savedIsContainerized;
|
|
43
|
+
}
|
|
44
|
+
if (savedCesCredentialUrl !== undefined) {
|
|
45
|
+
process.env.CES_CREDENTIAL_URL = savedCesCredentialUrl;
|
|
46
|
+
}
|
|
33
47
|
try {
|
|
34
48
|
rmSync(testDir, { recursive: true, force: true });
|
|
35
49
|
} catch {
|
|
@@ -41,7 +41,6 @@ let checkerDecision: "allow" | "prompt" | "deny" = "allow";
|
|
|
41
41
|
let checkerReason = "allowed";
|
|
42
42
|
let checkerRisk = "low";
|
|
43
43
|
let promptDecision: "allow" | "always_allow" | "deny" | "always_deny" = "allow";
|
|
44
|
-
let sandboxed = false;
|
|
45
44
|
let fakeToolResult: ToolExecutionResult = { content: "ok", isError: false };
|
|
46
45
|
let toolThrow: Error | null = null;
|
|
47
46
|
|
|
@@ -151,7 +150,7 @@ mock.module("../tools/shared/filesystem/path-policy.js", () => ({
|
|
|
151
150
|
}));
|
|
152
151
|
|
|
153
152
|
mock.module("../tools/terminal/sandbox.js", () => ({
|
|
154
|
-
wrapCommand: () => ({ command: "", sandboxed }),
|
|
153
|
+
wrapCommand: () => ({ command: "", sandboxed: false }),
|
|
155
154
|
}));
|
|
156
155
|
|
|
157
156
|
import { PermissionPrompter } from "../permissions/prompter.js";
|
|
@@ -193,7 +192,6 @@ describe("ToolExecutor lifecycle events", () => {
|
|
|
193
192
|
checkerReason = "allowed";
|
|
194
193
|
checkerRisk = "low";
|
|
195
194
|
promptDecision = "allow";
|
|
196
|
-
sandboxed = false;
|
|
197
195
|
fakeToolResult = { content: "ok", isError: false };
|
|
198
196
|
toolThrow = null;
|
|
199
197
|
});
|
|
@@ -231,7 +229,6 @@ describe("ToolExecutor lifecycle events", () => {
|
|
|
231
229
|
checkerReason = "medium risk: requires approval";
|
|
232
230
|
checkerRisk = "medium";
|
|
233
231
|
promptDecision = "deny";
|
|
234
|
-
sandboxed = true;
|
|
235
232
|
|
|
236
233
|
const events: ToolLifecycleEvent[] = [];
|
|
237
234
|
const executor = new ToolExecutor(makePrompter());
|
|
@@ -256,7 +253,6 @@ describe("ToolExecutor lifecycle events", () => {
|
|
|
256
253
|
expect(promptEvent.executionTarget).toBe("sandbox");
|
|
257
254
|
expect(promptEvent.riskLevel).toBe("medium");
|
|
258
255
|
expect(promptEvent.reason).toBe("medium risk: requires approval");
|
|
259
|
-
expect(promptEvent.sandboxed).toBe(true);
|
|
260
256
|
expect(promptEvent.allowlistOptions).toEqual([
|
|
261
257
|
{ label: "exact", description: "exact", pattern: "exact" },
|
|
262
258
|
]);
|
|
@@ -276,7 +272,6 @@ describe("ToolExecutor lifecycle events", () => {
|
|
|
276
272
|
checkerDecision = "prompt";
|
|
277
273
|
checkerReason = "guardrail prompt";
|
|
278
274
|
checkerRisk = "high";
|
|
279
|
-
sandboxed = true;
|
|
280
275
|
|
|
281
276
|
const events: ToolLifecycleEvent[] = [];
|
|
282
277
|
const executor = new ToolExecutor(
|
|
@@ -580,7 +575,6 @@ describe("ToolExecutor lifecycle events", () => {
|
|
|
580
575
|
checkerReason = "Matched trust rule";
|
|
581
576
|
checkerRisk = "low";
|
|
582
577
|
promptDecision = "allow";
|
|
583
|
-
sandboxed = true;
|
|
584
578
|
|
|
585
579
|
const events: ToolLifecycleEvent[] = [];
|
|
586
580
|
const executor = new ToolExecutor(makePrompter());
|
|
@@ -602,7 +596,6 @@ describe("ToolExecutor lifecycle events", () => {
|
|
|
602
596
|
expect(promptEvent.reason).toBe(
|
|
603
597
|
"Private conversation: side-effect tools require explicit approval",
|
|
604
598
|
);
|
|
605
|
-
expect(promptEvent.sandboxed).toBe(true);
|
|
606
599
|
});
|
|
607
600
|
|
|
608
601
|
test("no permission_prompt event for read-only tool even with forcePromptSideEffects", async () => {
|
|
@@ -1949,7 +1949,6 @@ describe("ToolExecutor persistentDecisionsAllowed contract", () => {
|
|
|
1949
1949
|
_allowlistOptions: AllowlistOption[],
|
|
1950
1950
|
_scopeOptions: ScopeOption[],
|
|
1951
1951
|
_diff: unknown,
|
|
1952
|
-
_sandboxed: unknown,
|
|
1953
1952
|
_conversationId: unknown,
|
|
1954
1953
|
_executionTarget: unknown,
|
|
1955
1954
|
persistentDecisionsAllowed: boolean | undefined,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
MacosTransportMetadata,
|
|
5
|
+
NonMacosTransportMetadata,
|
|
6
|
+
} from "../daemon/message-types/conversations.js";
|
|
7
|
+
import { buildTransportHints } from "../daemon/transport-hints.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// buildTransportHints
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("buildTransportHints", () => {
|
|
14
|
+
test("produces correct hints for macOS transport", () => {
|
|
15
|
+
const transport: MacosTransportMetadata = {
|
|
16
|
+
channelId: "vellum",
|
|
17
|
+
interfaceId: "macos",
|
|
18
|
+
hostHomeDir: "/Users/alice",
|
|
19
|
+
hostUsername: "alice",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const hints = buildTransportHints(transport);
|
|
23
|
+
|
|
24
|
+
expect(hints).toContain("User is messaging from interface: macos");
|
|
25
|
+
expect(hints).toContain("Host home directory: /Users/alice");
|
|
26
|
+
expect(hints).toContain("Host username: alice");
|
|
27
|
+
expect(hints).toHaveLength(3);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("produces correct hints for non-macOS transport", () => {
|
|
31
|
+
const transport: NonMacosTransportMetadata = {
|
|
32
|
+
channelId: "vellum",
|
|
33
|
+
interfaceId: "ios",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const hints = buildTransportHints(transport);
|
|
37
|
+
|
|
38
|
+
expect(hints).toContain("User is messaging from interface: ios");
|
|
39
|
+
expect(hints).toHaveLength(1);
|
|
40
|
+
// Should not include host environment hints
|
|
41
|
+
expect(hints.some((h) => h.includes("Host home directory"))).toBe(false);
|
|
42
|
+
expect(hints.some((h) => h.includes("Host username"))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("includes client-provided hints", () => {
|
|
46
|
+
const transport: MacosTransportMetadata = {
|
|
47
|
+
channelId: "vellum",
|
|
48
|
+
interfaceId: "macos",
|
|
49
|
+
hostHomeDir: "/Users/bob",
|
|
50
|
+
hostUsername: "bob",
|
|
51
|
+
hints: ["custom hint"],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const hints = buildTransportHints(transport);
|
|
55
|
+
|
|
56
|
+
expect(hints).toContain("User is messaging from interface: macos");
|
|
57
|
+
expect(hints).toContain("Host home directory: /Users/bob");
|
|
58
|
+
expect(hints).toContain("Host username: bob");
|
|
59
|
+
expect(hints).toContain("custom hint");
|
|
60
|
+
expect(hints).toHaveLength(4);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("handles missing optional fields on macOS transport", () => {
|
|
64
|
+
const transport: MacosTransportMetadata = {
|
|
65
|
+
channelId: "vellum",
|
|
66
|
+
interfaceId: "macos",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const hints = buildTransportHints(transport);
|
|
70
|
+
|
|
71
|
+
expect(hints).toContain("User is messaging from interface: macos");
|
|
72
|
+
// Without hostHomeDir and hostUsername, only the interface hint is present
|
|
73
|
+
expect(hints).toHaveLength(1);
|
|
74
|
+
expect(hints.some((h) => h.includes("Host home directory"))).toBe(false);
|
|
75
|
+
expect(hints.some((h) => h.includes("Host username"))).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -909,8 +909,8 @@ describe("Trust Store", () => {
|
|
|
909
909
|
expect(match!.id).toBe("default:allow-bash-rm-bootstrap");
|
|
910
910
|
expect(match!.decision).toBe("allow");
|
|
911
911
|
expect(match!.allowHighRisk).toBe(true);
|
|
912
|
-
// Outside workspace, the bootstrap rule doesn't match —
|
|
913
|
-
//
|
|
912
|
+
// Outside workspace, the bootstrap rule doesn't match — without
|
|
913
|
+
// IS_CONTAINERIZED there is no catch-all bash allow rule either.
|
|
914
914
|
const other = findHighestPriorityRule(
|
|
915
915
|
"bash",
|
|
916
916
|
["rm BOOTSTRAP.md"],
|
|
@@ -930,8 +930,8 @@ describe("Trust Store", () => {
|
|
|
930
930
|
expect(match!.id).toBe("default:allow-bash-rm-updates");
|
|
931
931
|
expect(match!.decision).toBe("allow");
|
|
932
932
|
expect(match!.allowHighRisk).toBe(true);
|
|
933
|
-
// Outside workspace, should NOT match the updates rule —
|
|
934
|
-
//
|
|
933
|
+
// Outside workspace, should NOT match the updates rule — without
|
|
934
|
+
// IS_CONTAINERIZED there is no catch-all bash allow rule either.
|
|
935
935
|
const other = findHighestPriorityRule(
|
|
936
936
|
"bash",
|
|
937
937
|
["rm UPDATES.md"],
|