@vellumai/assistant 0.8.11 → 0.8.12-staging.1
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/ARCHITECTURE.md +15 -17
- package/README.md +0 -6
- package/bun.lock +6 -122
- package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
- package/node_modules/@vellumai/gateway-client/package.json +3 -1
- package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
- package/openapi.yaml +126 -4
- package/package.json +1 -3
- package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
- package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
- package/src/__tests__/anthropic-provider.test.ts +129 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
- package/src/__tests__/btw-routes.test.ts +7 -34
- package/src/__tests__/checker.test.ts +6 -12
- package/src/__tests__/config-loader-backfill.test.ts +4 -2
- package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +8 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/disk-pressure-tools.test.ts +1 -1
- package/src/__tests__/exploration-drift-hook.test.ts +692 -0
- package/src/__tests__/filing-service.test.ts +8 -3
- package/src/__tests__/guardian-action-store.test.ts +0 -167
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
- package/src/__tests__/heartbeat-service.test.ts +5 -2
- package/src/__tests__/identity-intro-cache.test.ts +12 -5
- package/src/__tests__/identity-routes.test.ts +16 -57
- package/src/__tests__/injector-chain.test.ts +8 -3
- package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
- package/src/__tests__/llm-usage-store.test.ts +11 -0
- package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/oauth-cli.test.ts +19 -8
- package/src/__tests__/openai-provider.test.ts +34 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
- package/src/__tests__/recurrence-engine.test.ts +45 -0
- package/src/__tests__/schedule-routes.test.ts +34 -0
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
- package/src/__tests__/skill-tool-factory.test.ts +49 -0
- package/src/__tests__/subagent-role-registry.test.ts +24 -1
- package/src/__tests__/subagent-tools.test.ts +1 -0
- package/src/__tests__/system-prompt.test.ts +109 -11
- package/src/__tests__/tool-error-hook.test.ts +1 -0
- package/src/__tests__/tool-result-spool.test.ts +337 -0
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/validate-input.test.ts +95 -1
- package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
- package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
- package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
- package/src/agent/loop.ts +81 -24
- package/src/api/events/usage-progress.ts +28 -0
- package/src/api/index.ts +6 -0
- package/src/background-wake/wake-intent-hooks.test.ts +2 -0
- package/src/bundler/app-bundler.ts +25 -42
- package/src/calls/call-controller.ts +1 -1
- package/src/cli/commands/plugins.ts +248 -15
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
- package/src/cli/lib/inspect-plugin.ts +252 -0
- package/src/cli/lib/install-from-github.ts +214 -21
- package/src/cli/lib/list-installed-plugins.ts +17 -6
- package/src/cli/lib/plugin-artifact.ts +103 -0
- package/src/cli/lib/plugin-details.ts +18 -1
- package/src/cli/lib/plugin-fingerprint.ts +197 -0
- package/src/cli/lib/upgrade-plugin.ts +219 -0
- package/src/config/bundled-skills/subagent/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
- package/src/config/call-site-defaults.ts +13 -2
- package/src/config/feature-flag-registry.json +8 -16
- package/src/config/loader.ts +52 -59
- package/src/config/schema.ts +0 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
- package/src/config/schemas/llm.ts +10 -0
- package/src/config/schemas/memory-v2.ts +13 -0
- package/src/config/schemas/memory-v3.ts +92 -0
- package/src/context/post-turn-tool-result-truncation.ts +32 -18
- package/src/context/tool-result-spool.ts +104 -0
- package/src/credential-execution/feature-gates.ts +0 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
- package/src/daemon/conversation-error.ts +6 -15
- package/src/daemon/conversation.ts +9 -0
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +1 -20
- package/src/daemon/message-types/conversations.ts +2 -15
- package/src/daemon/trust-context.ts +1 -1
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
- package/src/home/__tests__/home-content-refresh.test.ts +114 -0
- package/src/home/__tests__/suggested-prompts.test.ts +86 -5
- package/src/home/home-content-refresh.ts +43 -31
- package/src/home/home-greeting-cache.ts +8 -1
- package/src/home/home-greeting.ts +13 -9
- package/src/home/suggested-prompts.ts +77 -24
- package/src/ipc/routes/trust-rules.test.ts +66 -72
- package/src/media/image-credentials.ts +2 -2
- package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
- package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
- package/src/memory/conversation-attention-store.ts +1 -0
- package/src/memory/conversation-bootstrap.ts +18 -9
- package/src/memory/conversation-crud.ts +12 -2
- package/src/memory/conversation-title-service.ts +53 -9
- package/src/memory/delivery-channels.ts +0 -69
- package/src/memory/graph/extraction-job.ts +0 -15
- package/src/memory/guardian-action-store.ts +1 -376
- package/src/memory/llm-usage-store.ts +5 -1
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
- package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
- package/src/memory/v2/__tests__/injection.test.ts +70 -0
- package/src/memory/v2/__tests__/static-context.test.ts +12 -0
- package/src/memory/v2/consolidation-job.ts +93 -9
- package/src/memory/v2/injection.ts +53 -0
- package/src/memory/v2/prompts/consolidation.ts +1 -0
- package/src/memory/v2/static-context.ts +13 -1
- package/src/memory/v2/sweep-job.ts +1 -1
- package/src/memory/v2/types.ts +5 -0
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
- package/src/plugins/defaults/exploration-drift/package.json +15 -0
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
- package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
- package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
- package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
- package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
- package/src/prompts/cache-boundary.ts +17 -0
- package/src/prompts/sections.ts +50 -17
- package/src/prompts/system-prompt.ts +12 -4
- package/src/prompts/templates/system-sections.ts +22 -0
- package/src/providers/anthropic/client.ts +74 -28
- package/src/providers/gemini/client.ts +5 -1
- package/src/providers/minimax/client.ts +9 -0
- package/src/providers/model-intents.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +2 -1
- package/src/providers/openai/responses-provider.ts +5 -1
- package/src/providers/retry.ts +8 -0
- package/src/providers/types.ts +11 -0
- package/src/runtime/AGENTS.md +6 -0
- package/src/runtime/__tests__/agent-wake.test.ts +2 -2
- package/src/runtime/agent-wake.ts +5 -5
- package/src/runtime/background-job-runner.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
- package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
- package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
- package/src/runtime/migrations/vbundle-importer.ts +9 -4
- package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
- package/src/runtime/pre-first-message-gate.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
- package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
- package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
- package/src/runtime/routes/btw-routes.ts +0 -14
- package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
- package/src/runtime/routes/conversation-list-routes.ts +77 -5
- package/src/runtime/routes/conversation-management-routes.ts +54 -0
- package/src/runtime/routes/gateway-log-routes.ts +14 -64
- package/src/runtime/routes/home-feed-routes.ts +10 -0
- package/src/runtime/routes/identity-intro-cache.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +76 -20
- package/src/runtime/routes/inbound-message-handler.ts +0 -36
- package/src/runtime/routes/plugins-routes.ts +21 -0
- package/src/runtime/routes/schedule-routes.ts +19 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -67
- package/src/schedule/recurrence-engine.ts +34 -0
- package/src/schedule/scheduler.ts +1 -0
- package/src/skills/validate-input.ts +41 -1
- package/src/subagent/types.ts +26 -1
- package/src/telemetry/types.ts +15 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -0
- package/src/tools/apps/executors.ts +1 -1
- package/src/tools/skills/skill-tool-factory.ts +19 -8
- package/src/usage/types.ts +8 -1
- package/src/util/platform.ts +16 -0
- package/src/watcher/engine.ts +1 -0
- package/src/workspace/adaptive-thinking-repair.ts +113 -0
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
- package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
- package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
- package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
- package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
- package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
- package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
- package/src/__tests__/update-bulletin-job.test.ts +0 -292
- package/src/config/schemas/updates.ts +0 -14
- package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
- package/src/memory/conversation-starters-cadence.ts +0 -78
- package/src/prompts/update-bulletin-job.ts +0 -180
- package/src/runtime/guardian-action-followup-executor.ts +0 -306
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for {@link parsePluginArtifact} — the `vellum.artifact` descriptor
|
|
3
|
+
* reader. A descriptor is surfaced only when both fields are well-formed
|
|
4
|
+
* (https URL + 64-hex sha256); every malformed or partial shape is "no
|
|
5
|
+
* artifact yet" (`null`) so callers never offer an unverifiable download.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import { parsePluginArtifact } from "../plugin-artifact.js";
|
|
11
|
+
|
|
12
|
+
const VALID_SHA = "a".repeat(64);
|
|
13
|
+
|
|
14
|
+
describe("parsePluginArtifact", () => {
|
|
15
|
+
test("returns the descriptor when url + sha256 are well-formed", () => {
|
|
16
|
+
// GIVEN a package.json with a complete vellum.artifact block
|
|
17
|
+
const pkg = {
|
|
18
|
+
vellum: {
|
|
19
|
+
artifact: {
|
|
20
|
+
url: "https://example.com/releases/v1.0.0/App.dmg",
|
|
21
|
+
sha256: VALID_SHA,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// WHEN we parse it
|
|
27
|
+
const artifact = parsePluginArtifact(pkg);
|
|
28
|
+
|
|
29
|
+
// THEN both fields come through unchanged
|
|
30
|
+
expect(artifact).toEqual({
|
|
31
|
+
url: "https://example.com/releases/v1.0.0/App.dmg",
|
|
32
|
+
sha256: VALID_SHA,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("surfaces an optional label when the plugin names the download", () => {
|
|
37
|
+
// GIVEN a block that labels its artifact (e.g. for multi-artifact plugins)
|
|
38
|
+
const pkg = {
|
|
39
|
+
vellum: {
|
|
40
|
+
artifact: {
|
|
41
|
+
url: "https://example.com/App.dmg",
|
|
42
|
+
sha256: VALID_SHA,
|
|
43
|
+
label: "Download for macOS",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// WHEN we parse it
|
|
49
|
+
const artifact = parsePluginArtifact(pkg);
|
|
50
|
+
|
|
51
|
+
// THEN the label comes through alongside url + sha256
|
|
52
|
+
expect(artifact).toEqual({
|
|
53
|
+
url: "https://example.com/App.dmg",
|
|
54
|
+
sha256: VALID_SHA,
|
|
55
|
+
label: "Download for macOS",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("trims a label and drops it when blank, keeping the descriptor", () => {
|
|
60
|
+
// GIVEN a descriptor whose label is whitespace-only
|
|
61
|
+
const pkg = {
|
|
62
|
+
vellum: {
|
|
63
|
+
artifact: {
|
|
64
|
+
url: "https://example.com/App.dmg",
|
|
65
|
+
sha256: VALID_SHA,
|
|
66
|
+
label: " ",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// WHEN we parse it
|
|
72
|
+
const artifact = parsePluginArtifact(pkg);
|
|
73
|
+
|
|
74
|
+
// THEN the blank label is dropped but the artifact still surfaces
|
|
75
|
+
expect(artifact).toEqual({
|
|
76
|
+
url: "https://example.com/App.dmg",
|
|
77
|
+
sha256: VALID_SHA,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("ignores a non-string label without dropping the descriptor", () => {
|
|
82
|
+
// GIVEN a label of the wrong type
|
|
83
|
+
const pkg = {
|
|
84
|
+
vellum: {
|
|
85
|
+
artifact: {
|
|
86
|
+
url: "https://example.com/App.dmg",
|
|
87
|
+
sha256: VALID_SHA,
|
|
88
|
+
label: 42,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// WHEN we parse it
|
|
94
|
+
const artifact = parsePluginArtifact(pkg);
|
|
95
|
+
|
|
96
|
+
// THEN the bad label is ignored but url + sha256 still come through
|
|
97
|
+
expect(artifact).toEqual({
|
|
98
|
+
url: "https://example.com/App.dmg",
|
|
99
|
+
sha256: VALID_SHA,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("ignores extra fields inside the artifact block", () => {
|
|
104
|
+
// GIVEN a block carrying fields beyond url + sha256
|
|
105
|
+
const pkg = {
|
|
106
|
+
vellum: {
|
|
107
|
+
artifact: {
|
|
108
|
+
url: "https://example.com/App.dmg",
|
|
109
|
+
sha256: VALID_SHA,
|
|
110
|
+
platform: "macos",
|
|
111
|
+
minOS: "13.0",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// WHEN we parse it
|
|
117
|
+
const artifact = parsePluginArtifact(pkg);
|
|
118
|
+
|
|
119
|
+
// THEN only the two recognised fields are surfaced
|
|
120
|
+
expect(artifact).toEqual({
|
|
121
|
+
url: "https://example.com/App.dmg",
|
|
122
|
+
sha256: VALID_SHA,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test.each([
|
|
127
|
+
["no vellum block", { name: "x" }],
|
|
128
|
+
["no artifact block", { vellum: {} }],
|
|
129
|
+
[
|
|
130
|
+
"empty sha256 (bootstrap placeholder)",
|
|
131
|
+
{
|
|
132
|
+
vellum: {
|
|
133
|
+
artifact: { url: "https://example.com/App.dmg", sha256: "" },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
[
|
|
138
|
+
"sha256 too short",
|
|
139
|
+
{
|
|
140
|
+
vellum: {
|
|
141
|
+
artifact: { url: "https://example.com/App.dmg", sha256: "abc" },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
"sha256 with uppercase hex",
|
|
147
|
+
{
|
|
148
|
+
vellum: {
|
|
149
|
+
artifact: {
|
|
150
|
+
url: "https://example.com/App.dmg",
|
|
151
|
+
sha256: "A".repeat(64),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
[
|
|
157
|
+
"non-https url",
|
|
158
|
+
{
|
|
159
|
+
vellum: {
|
|
160
|
+
artifact: { url: "http://example.com/App.dmg", sha256: VALID_SHA },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
["missing url", { vellum: { artifact: { sha256: VALID_SHA } } }],
|
|
165
|
+
["artifact is not an object", { vellum: { artifact: "App.dmg" } }],
|
|
166
|
+
["vellum is an array", { vellum: [] }],
|
|
167
|
+
])("returns null for %s", (_label, pkg) => {
|
|
168
|
+
// WHEN we parse a malformed / partial descriptor
|
|
169
|
+
// THEN nothing is surfaced
|
|
170
|
+
expect(parsePluginArtifact(pkg)).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test.each([
|
|
174
|
+
["null", null],
|
|
175
|
+
["a string", "not-a-package"],
|
|
176
|
+
["an array", []],
|
|
177
|
+
["a number", 42],
|
|
178
|
+
])("returns null when package.json is %s", (_label, value) => {
|
|
179
|
+
// WHEN we parse a non-object package.json value
|
|
180
|
+
// THEN nothing is surfaced
|
|
181
|
+
expect(parsePluginArtifact(value)).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -372,4 +372,162 @@ describe("getPluginDetails", () => {
|
|
|
372
372
|
),
|
|
373
373
|
).rejects.toThrow();
|
|
374
374
|
});
|
|
375
|
+
|
|
376
|
+
test("surfaces a well-formed vellum.artifact from the repo package.json", async () => {
|
|
377
|
+
// GIVEN an external plugin whose repo package.json declares a complete
|
|
378
|
+
// vellum.artifact (https url + 64-hex sha256)
|
|
379
|
+
const sha = "a".repeat(64);
|
|
380
|
+
const fetch = makeFetch({
|
|
381
|
+
marketplace: {
|
|
382
|
+
name: "vellum",
|
|
383
|
+
plugins: [
|
|
384
|
+
{
|
|
385
|
+
name: "dynamic-notch",
|
|
386
|
+
source: {
|
|
387
|
+
source: "github",
|
|
388
|
+
repo: "example-org/dynamic-notch",
|
|
389
|
+
ref: "1111111111111111111111111111111111111111",
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
listings: {
|
|
395
|
+
"example-org/dynamic-notch": [
|
|
396
|
+
fileEntry("package.json", "raw://notch/pkg"),
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
raw: {
|
|
400
|
+
"raw://notch/pkg": JSON.stringify({
|
|
401
|
+
version: "1.0.0",
|
|
402
|
+
vellum: {
|
|
403
|
+
artifact: {
|
|
404
|
+
url: "https://example.com/releases/v1.0.0/App.dmg",
|
|
405
|
+
sha256: sha,
|
|
406
|
+
label: "Download for macOS",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
}),
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// WHEN we resolve the detail view
|
|
414
|
+
const details = await getPluginDetails(
|
|
415
|
+
{ name: "dynamic-notch" },
|
|
416
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// THEN the artifact descriptor is surfaced, including its optional label
|
|
420
|
+
expect(details.artifact).toEqual({
|
|
421
|
+
url: "https://example.com/releases/v1.0.0/App.dmg",
|
|
422
|
+
sha256: sha,
|
|
423
|
+
label: "Download for macOS",
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("an installed copy's artifact wins over the repo's", async () => {
|
|
428
|
+
// GIVEN an installed copy whose package.json declares its own artifact
|
|
429
|
+
const localSha = "b".repeat(64);
|
|
430
|
+
const remoteSha = "c".repeat(64);
|
|
431
|
+
const target = join(workspace, "dynamic-notch");
|
|
432
|
+
mkdirSync(target, { recursive: true });
|
|
433
|
+
writeFileSync(
|
|
434
|
+
join(target, "package.json"),
|
|
435
|
+
JSON.stringify({
|
|
436
|
+
vellum: {
|
|
437
|
+
artifact: {
|
|
438
|
+
url: "https://example.com/local/App.dmg",
|
|
439
|
+
sha256: localSha,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// AND a marketplace entry + repo package.json declaring a different artifact
|
|
446
|
+
const fetch = makeFetch({
|
|
447
|
+
marketplace: {
|
|
448
|
+
name: "vellum",
|
|
449
|
+
plugins: [
|
|
450
|
+
{
|
|
451
|
+
name: "dynamic-notch",
|
|
452
|
+
source: {
|
|
453
|
+
source: "github",
|
|
454
|
+
repo: "example-org/dynamic-notch",
|
|
455
|
+
ref: "1111111111111111111111111111111111111111",
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
},
|
|
460
|
+
listings: {
|
|
461
|
+
"example-org/dynamic-notch": [
|
|
462
|
+
fileEntry("package.json", "raw://notch/pkg"),
|
|
463
|
+
],
|
|
464
|
+
},
|
|
465
|
+
raw: {
|
|
466
|
+
"raw://notch/pkg": JSON.stringify({
|
|
467
|
+
vellum: {
|
|
468
|
+
artifact: {
|
|
469
|
+
url: "https://example.com/remote/App.dmg",
|
|
470
|
+
sha256: remoteSha,
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
}),
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// WHEN we resolve the detail view
|
|
478
|
+
const details = await getPluginDetails(
|
|
479
|
+
{ name: "dynamic-notch" },
|
|
480
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// THEN the installed copy's artifact wins
|
|
484
|
+
expect(details.artifact).toEqual({
|
|
485
|
+
url: "https://example.com/local/App.dmg",
|
|
486
|
+
sha256: localSha,
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("treats a placeholder sha256 as no artifact yet", async () => {
|
|
491
|
+
// GIVEN a repo package.json with a url but an empty (placeholder) sha256 —
|
|
492
|
+
// the bootstrap state before a release workflow fills the digest in
|
|
493
|
+
const fetch = makeFetch({
|
|
494
|
+
marketplace: {
|
|
495
|
+
name: "vellum",
|
|
496
|
+
plugins: [
|
|
497
|
+
{
|
|
498
|
+
name: "dynamic-notch",
|
|
499
|
+
source: {
|
|
500
|
+
source: "github",
|
|
501
|
+
repo: "example-org/dynamic-notch",
|
|
502
|
+
ref: "1111111111111111111111111111111111111111",
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
},
|
|
507
|
+
listings: {
|
|
508
|
+
"example-org/dynamic-notch": [
|
|
509
|
+
fileEntry("package.json", "raw://notch/pkg"),
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
raw: {
|
|
513
|
+
"raw://notch/pkg": JSON.stringify({
|
|
514
|
+
vellum: {
|
|
515
|
+
artifact: {
|
|
516
|
+
url: "https://example.com/releases/v1.0.0/App.dmg",
|
|
517
|
+
sha256: "",
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// WHEN we resolve the detail view
|
|
525
|
+
const details = await getPluginDetails(
|
|
526
|
+
{ name: "dynamic-notch" },
|
|
527
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
// THEN no artifact is surfaced (a client must not offer an unverifiable download)
|
|
531
|
+
expect(details.artifact).toBeNull();
|
|
532
|
+
});
|
|
375
533
|
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the plugin content-fingerprint helpers.
|
|
3
|
+
*
|
|
4
|
+
* Each test materializes a real tree under a temp directory, fingerprints it,
|
|
5
|
+
* then mutates the tree and asserts the comparison reports the expected
|
|
6
|
+
* modified / added / removed paths. The sidecar is exercised through the
|
|
7
|
+
* `exclude` argument so it never counts as a local addition.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
symlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
compareFingerprint,
|
|
23
|
+
computeContentHash,
|
|
24
|
+
computeFingerprint,
|
|
25
|
+
parseFingerprint,
|
|
26
|
+
} from "../plugin-fingerprint.js";
|
|
27
|
+
|
|
28
|
+
let root: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
root = mkdtempSync(join(tmpdir(), "plugin-fingerprint-"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(root, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function write(rel: string, contents: string): void {
|
|
39
|
+
const abs = join(root, rel);
|
|
40
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
41
|
+
writeFileSync(abs, contents);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("computeFingerprint", () => {
|
|
45
|
+
test("digests every regular file with POSIX-relative keys", () => {
|
|
46
|
+
// GIVEN a tree with a nested file
|
|
47
|
+
write("package.json", "{}");
|
|
48
|
+
write("src/index.ts", "export const x = 1;");
|
|
49
|
+
|
|
50
|
+
// WHEN it is fingerprinted
|
|
51
|
+
const fp = computeFingerprint(root);
|
|
52
|
+
|
|
53
|
+
// THEN every file is keyed by its forward-slash relative path
|
|
54
|
+
expect(Object.keys(fp.files).sort()).toEqual([
|
|
55
|
+
"package.json",
|
|
56
|
+
"src/index.ts",
|
|
57
|
+
]);
|
|
58
|
+
expect(fp.algorithm).toBe("sha256");
|
|
59
|
+
expect(fp.files["src/index.ts"]).toMatch(/^[0-9a-f]{64}$/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("excludes named top-level entries (the sidecar)", () => {
|
|
63
|
+
// GIVEN a tree containing the provenance sidecar
|
|
64
|
+
write("package.json", "{}");
|
|
65
|
+
write("install-meta.json", '{"name":"x"}');
|
|
66
|
+
|
|
67
|
+
// WHEN it is fingerprinted excluding the sidecar
|
|
68
|
+
const fp = computeFingerprint(root, ["install-meta.json"]);
|
|
69
|
+
|
|
70
|
+
// THEN the sidecar is not part of the digest map
|
|
71
|
+
expect(Object.keys(fp.files)).toEqual(["package.json"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("skips symlinks", () => {
|
|
75
|
+
// GIVEN a tree with a real file and a symlink to it
|
|
76
|
+
write("real.txt", "hello");
|
|
77
|
+
symlinkSync(join(root, "real.txt"), join(root, "link.txt"));
|
|
78
|
+
|
|
79
|
+
// WHEN it is fingerprinted
|
|
80
|
+
const fp = computeFingerprint(root);
|
|
81
|
+
|
|
82
|
+
// THEN only the regular file is digested
|
|
83
|
+
expect(Object.keys(fp.files)).toEqual(["real.txt"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("compareFingerprint", () => {
|
|
88
|
+
test("reports clean when the tree is unchanged", () => {
|
|
89
|
+
// GIVEN a fingerprinted tree
|
|
90
|
+
write("a.txt", "one");
|
|
91
|
+
write("b.txt", "two");
|
|
92
|
+
const baseline = computeFingerprint(root);
|
|
93
|
+
|
|
94
|
+
// WHEN it is compared against itself untouched
|
|
95
|
+
const diff = compareFingerprint(root, baseline);
|
|
96
|
+
|
|
97
|
+
// THEN nothing is reported as changed
|
|
98
|
+
expect(diff.clean).toBe(true);
|
|
99
|
+
expect(diff.modified).toEqual([]);
|
|
100
|
+
expect(diff.added).toEqual([]);
|
|
101
|
+
expect(diff.removed).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("classifies modified, added, and removed files", () => {
|
|
105
|
+
// GIVEN a baseline of two files
|
|
106
|
+
write("keep.txt", "keep");
|
|
107
|
+
write("change.txt", "before");
|
|
108
|
+
const baseline = computeFingerprint(root);
|
|
109
|
+
|
|
110
|
+
// AND the tree is edited: one file changed, one added, one removed
|
|
111
|
+
write("change.txt", "after");
|
|
112
|
+
write("new.txt", "fresh");
|
|
113
|
+
rmSync(join(root, "keep.txt"));
|
|
114
|
+
|
|
115
|
+
// WHEN the tree is compared against the baseline
|
|
116
|
+
const diff = compareFingerprint(root, baseline);
|
|
117
|
+
|
|
118
|
+
// THEN each change is bucketed correctly
|
|
119
|
+
expect(diff.clean).toBe(false);
|
|
120
|
+
expect(diff.modified).toEqual(["change.txt"]);
|
|
121
|
+
expect(diff.added).toEqual(["new.txt"]);
|
|
122
|
+
expect(diff.removed).toEqual(["keep.txt"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("does not count an excluded sidecar as an addition", () => {
|
|
126
|
+
// GIVEN a baseline computed with the sidecar excluded
|
|
127
|
+
write("package.json", "{}");
|
|
128
|
+
const baseline = computeFingerprint(root, ["install-meta.json"]);
|
|
129
|
+
|
|
130
|
+
// AND the sidecar exists on disk (as it would post-install)
|
|
131
|
+
write("install-meta.json", '{"name":"x"}');
|
|
132
|
+
|
|
133
|
+
// WHEN compared with the same exclusion
|
|
134
|
+
const diff = compareFingerprint(root, baseline, ["install-meta.json"]);
|
|
135
|
+
|
|
136
|
+
// THEN the tree is still clean
|
|
137
|
+
expect(diff.clean).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("parseFingerprint", () => {
|
|
142
|
+
test("round-trips a computed fingerprint through JSON", () => {
|
|
143
|
+
// GIVEN a computed fingerprint serialized to JSON
|
|
144
|
+
write("a.txt", "one");
|
|
145
|
+
const fp = computeFingerprint(root);
|
|
146
|
+
|
|
147
|
+
// WHEN it is parsed back
|
|
148
|
+
const parsed = parseFingerprint(JSON.parse(JSON.stringify(fp)));
|
|
149
|
+
|
|
150
|
+
// THEN it matches the original
|
|
151
|
+
expect(parsed).toEqual(fp);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test.each([
|
|
155
|
+
["a non-object", 42],
|
|
156
|
+
["a wrong algorithm", { algorithm: "md5", files: {} }],
|
|
157
|
+
["a missing files map", { algorithm: "sha256" }],
|
|
158
|
+
["a non-string digest", { algorithm: "sha256", files: { a: 1 } }],
|
|
159
|
+
])("returns null for %s", (_label, value) => {
|
|
160
|
+
// WHEN a malformed value is parsed
|
|
161
|
+
const parsed = parseFingerprint(value);
|
|
162
|
+
|
|
163
|
+
// THEN it is rejected leniently
|
|
164
|
+
expect(parsed).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("computeContentHash", () => {
|
|
169
|
+
test("returns a v2-prefixed sha256 digest over the tree", () => {
|
|
170
|
+
// GIVEN a tree with a nested file
|
|
171
|
+
write("package.json", "{}");
|
|
172
|
+
write("src/index.ts", "export const x = 1;");
|
|
173
|
+
|
|
174
|
+
// WHEN the aggregate content hash is computed
|
|
175
|
+
const hash = computeContentHash(root);
|
|
176
|
+
|
|
177
|
+
// THEN it is the versioned sha256 hex format skills also use
|
|
178
|
+
expect(hash).toMatch(/^v2:[0-9a-f]{64}$/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("is independent of filesystem creation order", () => {
|
|
182
|
+
// GIVEN two trees with identical contents written in different orders
|
|
183
|
+
write("b.txt", "two");
|
|
184
|
+
write("a.txt", "one");
|
|
185
|
+
const first = computeContentHash(root);
|
|
186
|
+
|
|
187
|
+
rmSync(root, { recursive: true, force: true });
|
|
188
|
+
mkdirSync(root, { recursive: true });
|
|
189
|
+
write("a.txt", "one");
|
|
190
|
+
write("b.txt", "two");
|
|
191
|
+
|
|
192
|
+
// WHEN both are hashed
|
|
193
|
+
const second = computeContentHash(root);
|
|
194
|
+
|
|
195
|
+
// THEN the digests match — the walk visits files in path order
|
|
196
|
+
expect(second).toBe(first);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("changes when any file content changes", () => {
|
|
200
|
+
// GIVEN a baseline hash
|
|
201
|
+
write("a.txt", "one");
|
|
202
|
+
const baseline = computeContentHash(root);
|
|
203
|
+
|
|
204
|
+
// AND a file is edited
|
|
205
|
+
write("a.txt", "two");
|
|
206
|
+
|
|
207
|
+
// WHEN the hash is recomputed
|
|
208
|
+
const updated = computeContentHash(root);
|
|
209
|
+
|
|
210
|
+
// THEN it differs from the baseline
|
|
211
|
+
expect(updated).not.toBe(baseline);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("excludes named top-level entries (the sidecar)", () => {
|
|
215
|
+
// GIVEN a tree and a baseline computed with the sidecar excluded
|
|
216
|
+
write("package.json", "{}");
|
|
217
|
+
const baseline = computeContentHash(root, ["install-meta.json"]);
|
|
218
|
+
|
|
219
|
+
// AND the sidecar later exists on disk (as it would post-install)
|
|
220
|
+
write("install-meta.json", '{"name":"x"}');
|
|
221
|
+
|
|
222
|
+
// WHEN the hash is recomputed with the same exclusion
|
|
223
|
+
const after = computeContentHash(root, ["install-meta.json"]);
|
|
224
|
+
|
|
225
|
+
// THEN the sidecar does not perturb the digest
|
|
226
|
+
expect(after).toBe(baseline);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("is not collision-prone across path/content boundaries", () => {
|
|
230
|
+
// GIVEN two trees whose concatenated path+content bytes would collide
|
|
231
|
+
// without length-prefixing ("ab" + "c" vs "a" + "bc")
|
|
232
|
+
write("ab", "c");
|
|
233
|
+
const first = computeContentHash(root);
|
|
234
|
+
|
|
235
|
+
rmSync(root, { recursive: true, force: true });
|
|
236
|
+
mkdirSync(root, { recursive: true });
|
|
237
|
+
write("a", "bc");
|
|
238
|
+
|
|
239
|
+
// WHEN both are hashed
|
|
240
|
+
const second = computeContentHash(root);
|
|
241
|
+
|
|
242
|
+
// THEN the length-prefixed scheme keeps them distinct
|
|
243
|
+
expect(second).not.toBe(first);
|
|
244
|
+
});
|
|
245
|
+
});
|