@vellumai/assistant 0.11.4-dev.202608201710.fcf0423 → 0.11.4-dev.202608201915.5ca6ffa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/openapi.yaml +19 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/credential-routes.test.ts +22 -3
  4. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  5. package/src/__tests__/daemon-credential-client.test.ts +88 -0
  6. package/src/__tests__/dm-backfill.test.ts +31 -0
  7. package/src/__tests__/input-repairs.test.ts +207 -0
  8. package/src/__tests__/plugin-api-resolve-credential.test.ts +22 -13
  9. package/src/__tests__/plugin-api-store-credential.test.ts +38 -31
  10. package/src/__tests__/secret-routes-platform-proxy.test.ts +7 -0
  11. package/src/__tests__/skill-tool-factory.test.ts +161 -0
  12. package/src/__tests__/validate-input.test.ts +243 -7
  13. package/src/agent/loop.ts +1 -7
  14. package/src/cli/commands/__tests__/keys.test.ts +89 -8
  15. package/src/cli/commands/__tests__/plugins.test.ts +57 -1
  16. package/src/cli/commands/keys.help.ts +13 -1
  17. package/src/cli/commands/keys.ts +26 -15
  18. package/src/cli/commands/plugins.help.ts +4 -0
  19. package/src/cli/commands/plugins.ts +10 -0
  20. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +32 -1
  21. package/src/cli/lib/daemon-credential-client.ts +25 -3
  22. package/src/cli/lib/plugin-surfaces.ts +27 -0
  23. package/src/config/feature-flag-registry.json +10 -2
  24. package/src/plugin-api/credential-scope.test.ts +15 -0
  25. package/src/plugin-api/credential-scope.ts +14 -0
  26. package/src/plugin-api/index.ts +2 -2
  27. package/src/plugin-api/resolve-credential.ts +12 -8
  28. package/src/plugin-api/store-credential.ts +7 -5
  29. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  30. package/src/providers/connection-resolution.ts +2 -1
  31. package/src/providers/content-blocks.ts +22 -0
  32. package/src/providers/inference/__tests__/missing-credential-guard.test.ts +117 -0
  33. package/src/providers/inference/adapter-factory.ts +13 -1
  34. package/src/providers/inference/credential-usage.ts +37 -0
  35. package/src/providers/inference/missing-credential-guard.ts +110 -0
  36. package/src/runtime/http-errors.ts +1 -0
  37. package/src/runtime/routes/__tests__/credential-delete-in-use.test.ts +155 -0
  38. package/src/runtime/routes/credential-in-use.ts +85 -0
  39. package/src/runtime/routes/credential-routes.ts +29 -3
  40. package/src/runtime/routes/inbound-message-handler.ts +36 -7
  41. package/src/runtime/routes/secret-routes.ts +39 -8
  42. package/src/skills/validate-input.ts +177 -40
  43. package/src/tools/schema-transforms.ts +40 -5
  44. package/src/tools/shared/input-repairs.ts +160 -0
  45. package/src/tools/skills/skill-tool-factory.ts +18 -4
package/openapi.yaml CHANGED
@@ -9886,7 +9886,10 @@ paths:
9886
9886
  post:
9887
9887
  operationId: credentials_delete_post
9888
9888
  summary: Delete a credential
9889
- description: Remove a secret, its metadata, and any associated OAuth connection from the vault.
9889
+ description:
9890
+ Remove a secret, its metadata, and any associated OAuth connection from the vault. Refused with
9891
+ CREDENTIAL_IN_USE while an LLM provider connection resolves its auth through the credential, unless `force` is
9892
+ set.
9890
9893
  tags:
9891
9894
  - credentials
9892
9895
  requestBody:
@@ -9902,6 +9905,9 @@ paths:
9902
9905
  field:
9903
9906
  type: string
9904
9907
  description: Field name
9908
+ force:
9909
+ description: Delete even when provider connections depend on the credential
9910
+ type: boolean
9905
9911
  required:
9906
9912
  - service
9907
9913
  - field
@@ -9917,9 +9923,15 @@ paths:
9917
9923
  type: string
9918
9924
  field:
9919
9925
  type: string
9926
+ affectedConnections:
9927
+ type: array
9928
+ items:
9929
+ type: string
9930
+ description: Provider connections that depended on the deleted credential
9920
9931
  required:
9921
9932
  - service
9922
9933
  - field
9934
+ - affectedConnections
9923
9935
  additionalProperties: false
9924
9936
  /v1/credentials/inspect:
9925
9937
  post:
@@ -27991,7 +28003,9 @@ paths:
27991
28003
  delete:
27992
28004
  operationId: secrets_delete
27993
28005
  summary: Delete a secret
27994
- description: Remove a secret from the credential vault by name.
28006
+ description:
28007
+ Remove a secret from the credential vault by name. Refused with CREDENTIAL_IN_USE while an LLM provider
28008
+ connection resolves its auth through the credential, unless `force` is set.
27995
28009
  tags:
27996
28010
  - secrets
27997
28011
  requestBody:
@@ -28007,6 +28021,9 @@ paths:
28007
28021
  name:
28008
28022
  type: string
28009
28023
  description: Name of the secret to delete
28024
+ force:
28025
+ description: Delete even when provider connections depend on the credential
28026
+ type: boolean
28010
28027
  required:
28011
28028
  - type
28012
28029
  - name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608201710.fcf0423",
3
+ "version": "0.11.4-dev.202608201915.5ca6ffa",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -146,6 +146,7 @@ mock.module("../tools/credentials/broker.js", () => ({
146
146
  }));
147
147
 
148
148
  import { persistPromptedCredential } from "../credential-execution/prompted-credential.js";
149
+ import { initializeDb } from "../persistence/db-init.js";
149
150
  import {
150
151
  forChatMintsSince,
151
152
  resetForChatMintRegistryForTest,
@@ -158,6 +159,12 @@ import {
158
159
  } from "../runtime/reveal-success-registry.js";
159
160
  import { ROUTES } from "../runtime/routes/credential-routes.js";
160
161
 
162
+ // A delete looks up the provider connections that resolve their auth through
163
+ // the credential, so the inference schema has to exist. No connection is
164
+ // created here: the refusal is covered by
165
+ // runtime/routes/__tests__/credential-delete-in-use.test.ts.
166
+ await initializeDb();
167
+
161
168
  const setRoute = ROUTES.find((r) => r.operationId === "credentials_set");
162
169
  const listRoute = ROUTES.find((r) => r.operationId === "credentials_list");
163
170
  const deleteRoute = ROUTES.find((r) => r.operationId === "credentials_delete");
@@ -173,7 +180,11 @@ type ListResponse = {
173
180
  }>;
174
181
  managedCredentials: unknown[];
175
182
  };
176
- type DeleteResponse = { service: string; field: string };
183
+ type DeleteResponse = {
184
+ service: string;
185
+ field: string;
186
+ affectedConnections: string[];
187
+ };
177
188
 
178
189
  const SECRET_VALUE = "super-secret-token-value";
179
190
 
@@ -786,7 +797,11 @@ describe("credentials routes", () => {
786
797
  })) as DeleteResponse;
787
798
 
788
799
  // THEN the response echoes the identifiers
789
- expect(result).toEqual({ service: "vercel", field: "api_token" });
800
+ expect(result).toEqual({
801
+ service: "vercel",
802
+ field: "api_token",
803
+ affectedConnections: [],
804
+ });
790
805
 
791
806
  // AND the secret and metadata are gone
792
807
  expect(secureStore.has("vercel:api_token")).toBe(false);
@@ -811,7 +826,11 @@ describe("credentials routes", () => {
811
826
  })) as DeleteResponse;
812
827
 
813
828
  // THEN the response echoes the identifiers
814
- expect(result).toEqual({ service: "slack_channel", field: "user_token" });
829
+ expect(result).toEqual({
830
+ service: "slack_channel",
831
+ field: "user_token",
832
+ affectedConnections: [],
833
+ });
815
834
 
816
835
  // AND only the user_token is removed; bot + app tokens remain
817
836
  expect(secureStore.has("slack_channel:user_token")).toBe(false);
@@ -237,7 +237,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
237
237
  "tools/network/web-fetch.ts", // Firecrawl /scrape BYOK fetch provider API key lookup (firecrawl provider key)
238
238
  "workspace/default-provider-ensure.ts", // legacy anthropic echo disambiguation (vault key presence check)
239
239
  "providers/inference/connection-availability.ts", // shared (provider, connection) availability status (credential presence check only; value never leaves the helper)
240
- "plugin-api/resolve-credential.ts", // plugin-facing resolveCredential — reveal-equivalent plaintext read, scoped to the in-context plugin's own field
240
+ "plugin-api/resolve-credential.ts", // plugin-facing resolveCredential: reveal-equivalent plaintext read, scoped to the in-context plugin's own service
241
241
  "tools/credentials/store.ts", // shared credential write path (setSecureKeyAsync only; no reads) behind credentials/set and plugin-facing storeCredential
242
242
  ]);
243
243
 
@@ -104,6 +104,60 @@ describe("daemon credential client", () => {
104
104
  expect(result.error).toBe("Credential store is unreachable");
105
105
  });
106
106
 
107
+ test("refuses to delete around the in-use guard when the daemon is down", async () => {
108
+ /**
109
+ * The guard runs daemon-side, so a stopped daemon must not turn into an
110
+ * unchecked direct delete.
111
+ */
112
+
113
+ // GIVEN a stored credential and no reachable daemon
114
+ _ipcResponse = {
115
+ ok: false,
116
+ error: "Could not connect to assistant daemon. Is it running?",
117
+ };
118
+ await setSecureKeyViaDaemon("api_key", "guarded-provider", "keep-me");
119
+
120
+ // WHEN the delete is attempted without force
121
+ const result = await deleteSecureKeyViaDaemon(
122
+ "api_key",
123
+ "guarded-provider",
124
+ );
125
+
126
+ // THEN it is refused and the credential survives
127
+ expect(result.result).toBe("error");
128
+ expect(result.code).toBe("IN_USE_CHECK_UNAVAILABLE");
129
+ expect(result.error).toContain("--force");
130
+ expect(
131
+ await getSecureKeyAsync(credentialKey("guarded-provider", "api_key")),
132
+ ).toBe("keep-me");
133
+ });
134
+
135
+ test("deletes directly when the caller forces past the unavailable guard", async () => {
136
+ /**
137
+ * An explicit --force still works with the daemon down.
138
+ */
139
+
140
+ // GIVEN a stored credential and no reachable daemon
141
+ _ipcResponse = {
142
+ ok: false,
143
+ error: "Could not connect to assistant daemon. Is it running?",
144
+ };
145
+ await setSecureKeyViaDaemon("api_key", "forced-provider", "drop-me");
146
+
147
+ // WHEN the delete is forced
148
+ const result = await deleteSecureKeyViaDaemon(
149
+ "api_key",
150
+ "forced-provider",
151
+ true,
152
+ );
153
+
154
+ // THEN the credential is gone from secure storage
155
+ expect(result.result).toBe("deleted");
156
+ expect(
157
+ await getSecureKeyAsync(credentialKey("forced-provider", "api_key")),
158
+ ).toBeUndefined();
159
+ });
160
+
107
161
  test("returns not-found for 404 errors", async () => {
108
162
  _ipcResponse = {
109
163
  ok: false,
@@ -167,5 +221,39 @@ describe("daemon credential client", () => {
167
221
  body: { type: "credential", name: "github-app:pem" },
168
222
  });
169
223
  });
224
+
225
+ test("delete carries the caller's force choice past the in-use guard", async () => {
226
+ // GIVEN a daemon that accepts the delete
227
+ _ipcResponse = { ok: true, result: { success: true } };
228
+ _lastIpcCall = null;
229
+
230
+ // WHEN the caller forces the delete
231
+ await deleteSecureKeyViaDaemon("api_key", "agentrouter", true);
232
+
233
+ // THEN `force` rides along in the request body
234
+ const captured = _lastIpcCall as {
235
+ method: string;
236
+ params: unknown;
237
+ } | null;
238
+ expect(captured?.params).toEqual({
239
+ body: { type: "api_key", name: "agentrouter", force: true },
240
+ });
241
+ });
242
+
243
+ test("delete surfaces the in-use refusal from the daemon", async () => {
244
+ // GIVEN a credential an LLM provider connection resolves its auth through
245
+ _ipcResponse = {
246
+ ok: false,
247
+ error:
248
+ 'Credential credential/agentrouter/api_key is in use by connection "agentrouter". It will stop working without it. Delete it anyway to continue.',
249
+ };
250
+
251
+ // WHEN the delete is attempted without force
252
+ const result = await deleteSecureKeyViaDaemon("api_key", "agentrouter");
253
+
254
+ // THEN the refusal reaches the caller naming the dependent connection
255
+ expect(result.result).toBe("error");
256
+ expect(result.error).toContain('in use by connection "agentrouter"');
257
+ });
170
258
  });
171
259
  });
@@ -587,4 +587,35 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
587
587
  const texts = rows.map((r) => r.content).sort();
588
588
  expect(texts).toEqual(["older A", "older B", "older D"]);
589
589
  });
590
+
591
+ test("backfill refuses history rows carrying a secret, keeping the rest", async () => {
592
+ const leakedKey = "AKIA3H7QWERTY9MNBVC2";
593
+ backfillDmMock.mockImplementation(async () => [
594
+ makeBackfilledMessage({
595
+ id: "1700000000.000001",
596
+ text: "ordinary older chatter",
597
+ }),
598
+ makeBackfilledMessage({
599
+ id: "1700000000.000002",
600
+ text: `here is the access key ${leakedKey} for the deploy`,
601
+ }),
602
+ ]);
603
+
604
+ await handleChannelInbound(
605
+ buildDmRequest("live new DM"),
606
+ noopProcessMessage,
607
+ TEST_BEARER_TOKEN,
608
+ );
609
+
610
+ const rows = readPersistedSlackRows();
611
+ expect(rows.map((r) => r.content)).toEqual(["ordinary older chatter"]);
612
+ // `rawContent`, not `content`: the latter is unwrapped by the test
613
+ // helper, so only the raw column proves what reached storage.
614
+ for (const row of rows) {
615
+ expect(row.rawContent).not.toContain(leakedKey);
616
+ }
617
+ expect(
618
+ rows.some((r) => r.slackMeta?.channelTs === "1700000000.000002"),
619
+ ).toBe(false);
620
+ });
590
621
  });
@@ -0,0 +1,207 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ import type { SkillToolEntry } from "../config/skills.js";
6
+ import {
7
+ bundledToolInputAliases,
8
+ bundledToolInputRepairs,
9
+ } from "../tools/shared/input-repairs.js";
10
+
11
+ const TOOL = "scaffold_managed_skill";
12
+ const FIND_TOOL = "find_similar_skills";
13
+
14
+ /** The real manifest entries the repairs describe. */
15
+ function manifestEntry(name: string): SkillToolEntry {
16
+ const manifest = JSON.parse(
17
+ readFileSync(
18
+ join(
19
+ import.meta.dir,
20
+ "../config/bundled-skills/skill-management/TOOLS.json",
21
+ ),
22
+ "utf-8",
23
+ ),
24
+ ) as { tools: SkillToolEntry[] };
25
+ const entry = manifest.tools.find((t) => t.name === name);
26
+ if (!entry) {
27
+ throw new Error(`${name} is missing from the skill-management TOOLS.json`);
28
+ }
29
+ return entry;
30
+ }
31
+
32
+ function declaredProperties(name: string): Record<string, { type?: string }> {
33
+ const schema = manifestEntry(name).input_schema as {
34
+ properties: Record<string, { type?: string }>;
35
+ };
36
+ return schema.properties;
37
+ }
38
+
39
+ describe("bundledToolInputRepairs: parameter aliases", () => {
40
+ test("reads `body` as the declared `body_markdown`", () => {
41
+ const result = bundledToolInputRepairs(TOOL, {
42
+ skill_id: "deploy",
43
+ body: "# Deploy\n",
44
+ });
45
+ expect(result).toEqual({ skill_id: "deploy", body_markdown: "# Deploy\n" });
46
+ expect("body" in result).toBe(false);
47
+ });
48
+
49
+ test("never overwrites a call that spells the parameter correctly", () => {
50
+ const input = { body: "alias", body_markdown: "declared" };
51
+ const result = bundledToolInputRepairs(TOOL, input);
52
+ expect(result).toBe(input);
53
+ });
54
+
55
+ test("leaves a tool with no alias table alone", () => {
56
+ const input = { body: "# Deploy\n" };
57
+ expect(bundledToolInputRepairs("file_write", input)).toBe(input);
58
+ });
59
+
60
+ test.each([TOOL, FIND_TOOL])(
61
+ "%s: no alias shadows a parameter the tool declares",
62
+ (tool) => {
63
+ const declared = declaredProperties(tool);
64
+ const aliases = bundledToolInputAliases(tool);
65
+ expect(aliases.length).toBeGreaterThan(0);
66
+ for (const alias of aliases) {
67
+ expect(declared[alias]).toBeUndefined();
68
+ }
69
+ },
70
+ );
71
+
72
+ test.each([TOOL, FIND_TOOL])(
73
+ "%s: every alias targets a parameter the tool declares",
74
+ (tool) => {
75
+ const declared = declaredProperties(tool);
76
+ for (const alias of bundledToolInputAliases(tool)) {
77
+ const result = bundledToolInputRepairs(tool, { [alias]: "value" });
78
+ const target = Object.keys(result).find((key) => key !== alias);
79
+ expect(target).toBeDefined();
80
+ expect(declared[target!]).toBeDefined();
81
+ }
82
+ },
83
+ );
84
+
85
+ test.each([
86
+ ["title", "name"],
87
+ ["summary", "description"],
88
+ ["content", "body_markdown"],
89
+ ])("reads `%s` as `%s`", (alias, canonical) => {
90
+ const result = bundledToolInputRepairs(TOOL, { [alias]: "value" });
91
+ expect(result).toEqual({ [canonical]: "value" });
92
+ });
93
+
94
+ test.each(["description", "query"])(
95
+ "find_similar_skills reads `%s` as `goal`",
96
+ (alias) => {
97
+ const result = bundledToolInputRepairs(FIND_TOOL, {
98
+ [alias]: "deploy staging",
99
+ });
100
+ expect(result).toEqual({ goal: "deploy staging" });
101
+ },
102
+ );
103
+ });
104
+
105
+ describe("bundledToolInputRepairs: files written as a path-keyed map", () => {
106
+ test("reads string values as each file's contents", () => {
107
+ const result = bundledToolInputRepairs(TOOL, {
108
+ files: {
109
+ "references/failure-modes.md": "The retry loop stalls.",
110
+ "scripts/run.sh": "echo hi",
111
+ },
112
+ });
113
+ expect(result.files).toEqual([
114
+ {
115
+ path: "references/failure-modes.md",
116
+ content: "The retry loop stalls.",
117
+ },
118
+ { path: "scripts/run.sh", content: "echo hi" },
119
+ ]);
120
+ });
121
+
122
+ test("reads an entry that names content or copy_from", () => {
123
+ const result = bundledToolInputRepairs(TOOL, {
124
+ files: {
125
+ "references/notes.md": { content: "hi" },
126
+ "scripts/run.sh": { copy_from: "/tmp/run.sh" },
127
+ },
128
+ });
129
+ expect(result.files).toEqual([
130
+ { path: "references/notes.md", content: "hi" },
131
+ { path: "scripts/run.sh", copy_from: "/tmp/run.sh" },
132
+ ]);
133
+ });
134
+
135
+ test("leaves one entry spelling out its own fields, which is a file not a map", () => {
136
+ const input = { files: { path: "references/notes.md", content: "hi" } };
137
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
138
+ });
139
+
140
+ test("leaves the whole value when any entry is unreadable", () => {
141
+ const input = {
142
+ files: {
143
+ "references/notes.md": "hi",
144
+ "scripts/run.sh": { mode: 755 },
145
+ },
146
+ };
147
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
148
+ });
149
+
150
+ test("leaves a map with a blank path", () => {
151
+ const input = { files: { " ": "hi" } };
152
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
153
+ });
154
+
155
+ test("leaves an empty object", () => {
156
+ const input = { files: {} };
157
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
158
+ });
159
+
160
+ test("an entry spelling out every declared file property is read as one file", () => {
161
+ const schema = manifestEntry(TOOL).input_schema as {
162
+ properties: {
163
+ files: { items: { properties: Record<string, unknown> } };
164
+ };
165
+ };
166
+ const declared = Object.keys(schema.properties.files.items.properties);
167
+ expect(declared).toContain("path");
168
+ const entry = Object.fromEntries(declared.map((key) => [key, "value"]));
169
+
170
+ const input = { files: entry };
171
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
172
+ });
173
+
174
+ test("leaves files that already arrived as an array", () => {
175
+ const input = { files: [{ path: "references/notes.md", content: "hi" }] };
176
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
177
+ });
178
+ });
179
+
180
+ describe("bundledToolInputRepairs: purity", () => {
181
+ test("returns the same object when there is nothing to repair", () => {
182
+ const input = { skill_id: "deploy", body_markdown: "# Deploy\n" };
183
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
184
+ });
185
+
186
+ test("returns a new object and never mutates the original input", () => {
187
+ const input = {
188
+ body: "# Deploy\n",
189
+ files: { "references/notes.md": "hi" },
190
+ };
191
+ const snapshot = JSON.parse(JSON.stringify(input));
192
+ const result = bundledToolInputRepairs(TOOL, input);
193
+ expect(result).not.toBe(input);
194
+ expect(input).toEqual(snapshot);
195
+ });
196
+
197
+ test("repairs an alias and a files map in the same call", () => {
198
+ const result = bundledToolInputRepairs(TOOL, {
199
+ body: "# Deploy\n",
200
+ files: { "references/notes.md": "hi" },
201
+ });
202
+ expect(result).toEqual({
203
+ body_markdown: "# Deploy\n",
204
+ files: [{ path: "references/notes.md", content: "hi" }],
205
+ });
206
+ });
207
+ });
@@ -102,21 +102,28 @@ describe("resolveCredential", () => {
102
102
  });
103
103
 
104
104
  describe("plugin scoping", () => {
105
- test("allows a plugin to resolve a credential whose field matches its name", async () => {
106
- seedCredential("openai", "acme", "scoped-secret");
107
- const value = await runInPluginContext("acme", () =>
108
- resolveCredential("openai/acme"),
105
+ test("allows a plugin to resolve a credential whose service matches its name", async () => {
106
+ seedCredential("imessage", "api_key", "comms-key");
107
+ const value = await runInPluginContext("imessage", () =>
108
+ resolveCredential("imessage/api_key"),
109
109
  );
110
- expect(value).toBe("scoped-secret");
110
+ expect(value).toBe("comms-key");
111
111
  });
112
112
 
113
- test("blocks a plugin from resolving a credential whose field differs from its name", async () => {
113
+ test("blocks a plugin from resolving another service", async () => {
114
114
  seedCredential("openai", "api_key", "sk-secret");
115
115
  await expect(
116
116
  runInPluginContext("acme", () => resolveCredential("openai/api_key")),
117
117
  ).rejects.toThrow(/out of scope/);
118
118
  });
119
119
 
120
+ test("blocks a plugin from resolving another service even when the field matches its name", async () => {
121
+ seedCredential("openai", "acme", "scoped-secret");
122
+ await expect(
123
+ runInPluginContext("acme", () => resolveCredential("openai/acme")),
124
+ ).rejects.toThrow(/out of scope/);
125
+ });
126
+
120
127
  test("does not read the secure backend when out of scope", async () => {
121
128
  seedCredential("openai", "api_key", "sk-secret");
122
129
  getSpy.mockClear();
@@ -126,15 +133,17 @@ describe("resolveCredential", () => {
126
133
  expect(getSpy).not.toHaveBeenCalled();
127
134
  });
128
135
 
129
- test("scoping applies by field only, across any service", async () => {
130
- seedCredential("stripe", "acme", "stripe-secret");
131
- seedCredential("openai", "acme", "openai-secret");
136
+ test("scoping applies to every field under the plugin's service", async () => {
137
+ seedCredential("imessage", "api_key", "comms-key");
138
+ seedCredential("imessage", "photon_project_id", "proj-id");
132
139
  await expect(
133
- runInPluginContext("acme", () => resolveCredential("stripe/acme")),
134
- ).resolves.toBe("stripe-secret");
140
+ runInPluginContext("imessage", () => resolveCredential("imessage/api_key")),
141
+ ).resolves.toBe("comms-key");
135
142
  await expect(
136
- runInPluginContext("acme", () => resolveCredential("openai/acme")),
137
- ).resolves.toBe("openai-secret");
143
+ runInPluginContext("imessage", () =>
144
+ resolveCredential("imessage/photon_project_id"),
145
+ ),
146
+ ).resolves.toBe("proj-id");
138
147
  });
139
148
  });
140
149
  });