@vellumai/assistant 0.10.9-dev.202607161044.1a55a2d → 0.10.9-dev.202607161241.f69e16e

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.9-dev.202607161044.1a55a2d",
3
+ "version": "0.10.9-dev.202607161241.f69e16e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -231,6 +231,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
231
231
  "tools/network/web-fetch.ts", // Firecrawl /scrape BYOK fetch provider API key lookup (firecrawl provider key)
232
232
  "workspace/default-provider-ensure.ts", // legacy anthropic echo disambiguation (vault key presence check)
233
233
  "providers/inference/connection-availability.ts", // shared (provider, connection) availability status (credential presence check only; value never leaves the helper)
234
+ "plugin-api/resolve-credential.ts", // plugin-facing resolveCredential — reveal-equivalent plaintext read, scoped to the in-context plugin's own field
234
235
  ]);
235
236
 
236
237
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -0,0 +1,140 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ afterAll,
7
+ afterEach,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ spyOn,
12
+ test,
13
+ } from "bun:test";
14
+
15
+ import { credentialKey } from "@vellumai/credential-storage";
16
+
17
+ import {
18
+ CredentialResolutionError,
19
+ resolveCredential,
20
+ } from "../plugin-api/resolve-credential.js";
21
+ import { runInPluginContext } from "../plugins/plugin-execution-context.js";
22
+ import * as secureKeys from "../security/secure-keys.js";
23
+ import {
24
+ _setMetadataPath,
25
+ upsertCredentialMetadata,
26
+ } from "../tools/credentials/metadata-store.js";
27
+
28
+ // Real metadata store backed by a temp file (no module mocking — a mock.module
29
+ // on metadata-store / secure-keys would replace the whole module namespace and
30
+ // leak into other test files in the same process). Only the secure backend read
31
+ // is intercepted, via a restorable spy.
32
+
33
+ const TEST_DIR = join(
34
+ tmpdir(),
35
+ `vellum-plugin-resolvecred-${randomBytes(4).toString("hex")}`,
36
+ );
37
+ const META_PATH = join(TEST_DIR, "metadata.json");
38
+
39
+ let secureStore: Map<string, string>;
40
+ let unreachable: boolean;
41
+ let getSpy: ReturnType<typeof spyOn>;
42
+
43
+ function seedCredential(service: string, field: string, value: string): string {
44
+ const meta = upsertCredentialMetadata(service, field, {});
45
+ secureStore.set(credentialKey(service, field), value);
46
+ return meta.credentialId;
47
+ }
48
+
49
+ beforeEach(() => {
50
+ if (existsSync(TEST_DIR)) {
51
+ rmSync(TEST_DIR, { recursive: true });
52
+ }
53
+ mkdirSync(TEST_DIR, { recursive: true });
54
+ _setMetadataPath(META_PATH);
55
+
56
+ secureStore = new Map();
57
+ unreachable = false;
58
+ getSpy = spyOn(secureKeys, "getSecureKeyResultAsync").mockImplementation(
59
+ async (key: string) => ({
60
+ value: secureStore.get(key),
61
+ unreachable: unreachable && !secureStore.has(key),
62
+ }),
63
+ );
64
+ });
65
+
66
+ afterEach(() => {
67
+ getSpy.mockRestore();
68
+ });
69
+
70
+ afterAll(() => {
71
+ _setMetadataPath(null);
72
+ if (existsSync(TEST_DIR)) {
73
+ rmSync(TEST_DIR, { recursive: true });
74
+ }
75
+ });
76
+
77
+ describe("resolveCredential", () => {
78
+ test("resolves plaintext by service/field ref when no plugin is in context", async () => {
79
+ seedCredential("openai", "api_key", "sk-secret");
80
+ await expect(resolveCredential("openai/api_key")).resolves.toBe(
81
+ "sk-secret",
82
+ );
83
+ });
84
+
85
+ test("resolves plaintext by credential UUID", async () => {
86
+ const id = seedCredential("stripe", "acme", "stripe-secret");
87
+ await expect(resolveCredential(id)).resolves.toBe("stripe-secret");
88
+ });
89
+
90
+ test("throws not found for an unknown ref", async () => {
91
+ await expect(resolveCredential("nope/missing")).rejects.toBeInstanceOf(
92
+ CredentialResolutionError,
93
+ );
94
+ });
95
+
96
+ test("throws when the store is unreachable", async () => {
97
+ upsertCredentialMetadata("openai", "api_key", {});
98
+ unreachable = true;
99
+ await expect(resolveCredential("openai/api_key")).rejects.toThrow(
100
+ /unreachable/,
101
+ );
102
+ });
103
+
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"),
109
+ );
110
+ expect(value).toBe("scoped-secret");
111
+ });
112
+
113
+ test("blocks a plugin from resolving a credential whose field differs from its name", async () => {
114
+ seedCredential("openai", "api_key", "sk-secret");
115
+ await expect(
116
+ runInPluginContext("acme", () => resolveCredential("openai/api_key")),
117
+ ).rejects.toThrow(/out of scope/);
118
+ });
119
+
120
+ test("does not read the secure backend when out of scope", async () => {
121
+ seedCredential("openai", "api_key", "sk-secret");
122
+ getSpy.mockClear();
123
+ await expect(
124
+ runInPluginContext("acme", () => resolveCredential("openai/api_key")),
125
+ ).rejects.toThrow(CredentialResolutionError);
126
+ expect(getSpy).not.toHaveBeenCalled();
127
+ });
128
+
129
+ test("scoping applies by field only, across any service", async () => {
130
+ seedCredential("stripe", "acme", "stripe-secret");
131
+ seedCredential("openai", "acme", "openai-secret");
132
+ await expect(
133
+ runInPluginContext("acme", () => resolveCredential("stripe/acme")),
134
+ ).resolves.toBe("stripe-secret");
135
+ await expect(
136
+ runInPluginContext("acme", () => resolveCredential("openai/acme")),
137
+ ).resolves.toBe("openai-secret");
138
+ });
139
+ });
140
+ });
@@ -150,6 +150,16 @@ export { getModelProfiles } from "./model-profiles.js";
150
150
  // looks up the model catalog's `supportsVision` flag (mix profiles are
151
151
  // vision-capable if any arm is). Returns false when nothing resolves.
152
152
  export { doesSupportVision } from "./vision-support.js";
153
+ // Resolve a stored credential to its plaintext value — the same value
154
+ // `assistant credentials reveal` prints — from a UUID or a "service/field"
155
+ // reference. When a plugin is in context, resolution is scoped to credentials
156
+ // whose `field` matches the plugin's manifest name; outside any plugin it is
157
+ // unscoped. Throws CredentialResolutionError when the ref does not resolve, the
158
+ // store is unreachable, or the credential is out of the plugin's scope.
159
+ export {
160
+ CredentialResolutionError,
161
+ resolveCredential,
162
+ } from "./resolve-credential.js";
153
163
  // Resolve a provider for a call site (optionally overriding the profile) so a
154
164
  // plugin can run inference through the workspace's configured profiles and
155
165
  // credentials — managed-proxy or BYOK — without supplying its own API key.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Plugin-facing credential resolution.
3
+ *
4
+ * {@link resolveCredential} returns a stored credential's plaintext value — the
5
+ * same value `assistant credentials reveal` prints — resolving a reference that
6
+ * is either a credential UUID or a `"service/field"` string, exactly as the
7
+ * reveal path does (see {@link ../tools/credentials/resolve.resolveCredentialRef}
8
+ * and the `credentials/reveal` route).
9
+ *
10
+ * ## Plugin scoping
11
+ *
12
+ * When a plugin is in context — its hook or tool is executing, tracked by
13
+ * {@link ../plugins/plugin-execution-context.getCurrentPluginName} — resolution
14
+ * is restricted: the plugin may only resolve credentials whose `field` equals
15
+ * the plugin's own manifest name. A plugin named `acme` can therefore read
16
+ * `openai/acme` or `stripe/acme` but not `openai/api_key`. Outside any plugin
17
+ * context (host-internal callers, CLI, tests) the resolver is unscoped and
18
+ * behaves like a direct reveal.
19
+ */
20
+
21
+ import { getCurrentPluginName } from "../plugins/plugin-execution-context.js";
22
+ import { getSecureKeyResultAsync } from "../security/secure-keys.js";
23
+ import { resolveCredentialRef } from "../tools/credentials/resolve.js";
24
+
25
+ /**
26
+ * Raised when a credential cannot be resolved: the reference does not match a
27
+ * stored credential, the store is unreachable, or the calling plugin is not
28
+ * permitted to resolve the requested credential.
29
+ */
30
+ export class CredentialResolutionError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "CredentialResolutionError";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Resolve a credential reference to its plaintext value.
39
+ *
40
+ * @param ref A credential UUID or a `"service/field"` string.
41
+ * @returns The plaintext credential value.
42
+ * @throws {CredentialResolutionError} when the reference does not resolve, the
43
+ * store is unreachable, or a plugin in context is not scoped to the credential.
44
+ */
45
+ export async function resolveCredential(ref: string): Promise<string> {
46
+ const resolved = resolveCredentialRef(ref);
47
+ if (!resolved) {
48
+ throw new CredentialResolutionError(`Credential not found: ${ref}`);
49
+ }
50
+
51
+ // Scope the resolution to the plugin in context, if any. The field-name gate
52
+ // is enforced before the plaintext is read so an out-of-scope plugin never
53
+ // touches the secure backend.
54
+ const pluginName = getCurrentPluginName();
55
+ if (pluginName !== undefined && resolved.field !== pluginName) {
56
+ throw new CredentialResolutionError(
57
+ `Plugin "${pluginName}" may only resolve credentials whose field matches its name; ` +
58
+ `"${resolved.service}/${resolved.field}" is out of scope.`,
59
+ );
60
+ }
61
+
62
+ const { value, unreachable } = await getSecureKeyResultAsync(
63
+ resolved.storageKey,
64
+ );
65
+ if (value == null || value.length === 0) {
66
+ if (unreachable) {
67
+ throw new CredentialResolutionError(
68
+ "Credential store is unreachable — ensure the assistant is running",
69
+ );
70
+ }
71
+ throw new CredentialResolutionError(`Credential not found: ${ref}`);
72
+ }
73
+
74
+ return value;
75
+ }
@@ -25,6 +25,7 @@ import { getHookEntriesFor } from "../hooks/registry.js";
25
25
  import type { BaseHookContext } from "../hooks/types.js";
26
26
  import { type HookName, HOOKS } from "../plugin-api/constants.js";
27
27
  import { getLogger } from "../util/logger.js";
28
+ import { runInPluginContext } from "./plugin-execution-context.js";
28
29
  import type { HookEntry } from "./types.js";
29
30
 
30
31
  // ─── Hook runner ────────────────────────────────────────────────────────────
@@ -352,8 +353,15 @@ export async function runHook<TInput extends object>(
352
353
  broadcast: makeHookBroadcast({ conversationId, hookName: name, owner }),
353
354
  };
354
355
  try {
356
+ // Mark the contributing plugin as in context so host APIs the hook
357
+ // reaches (e.g. resolveCredential) can scope to it. Standalone workspace
358
+ // hooks are not plugins and establish no context.
359
+ const invokeHook =
360
+ owner.kind === "plugin"
361
+ ? () => runInPluginContext(owner.id, () => fn(draft))
362
+ : () => fn(draft);
355
363
  const result = await callWithTimeout(
356
- () => fn(draft),
364
+ invokeHook,
357
365
  HOOK_TIMEOUT_MS,
358
366
  `plugin hook '${name}' (${owner.id}) timed out after ${HOOK_TIMEOUT_MS}ms`,
359
367
  );
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Plugin execution context — tracks which plugin's code is currently running.
3
+ *
4
+ * Host APIs exposed to plugins through `@vellumai/plugin-api` sometimes need to
5
+ * know *which* plugin is calling them so they can scope their behavior to that
6
+ * plugin (e.g. {@link ../plugin-api/resolve-credential.resolveCredential} limits
7
+ * a plugin to its own credentials). A plugin's manifest name is not threaded
8
+ * through every host call, so the pipeline that invokes a plugin's hook — and
9
+ * the tool executor that runs a plugin's tool — mark the plugin as "in context"
10
+ * for the duration of that invocation via an {@link AsyncLocalStorage}. Host
11
+ * APIs read {@link getCurrentPluginName} to recover it.
12
+ *
13
+ * The store propagates across `await` boundaries, so a plugin that awaits a
14
+ * host API deep inside its hook/tool body is still seen as in context. When no
15
+ * plugin is in context (host-internal callers, the CLI, tests), the store is
16
+ * empty and scoped APIs fall back to their unscoped behavior.
17
+ */
18
+
19
+ import { AsyncLocalStorage } from "node:async_hooks";
20
+
21
+ export interface PluginExecutionContext {
22
+ /** Manifest name of the plugin whose hook or tool is currently executing. */
23
+ pluginName: string;
24
+ }
25
+
26
+ const storage = new AsyncLocalStorage<PluginExecutionContext>();
27
+
28
+ /**
29
+ * Run `fn` with `pluginName` marked as the plugin currently in context. The
30
+ * returned value (including a promise) carries the context across its async
31
+ * continuations, so callers pass the promise straight to a timeout wrapper
32
+ * without losing the binding.
33
+ */
34
+ export function runInPluginContext<T>(pluginName: string, fn: () => T): T {
35
+ return storage.run({ pluginName }, fn);
36
+ }
37
+
38
+ /**
39
+ * Name of the plugin whose hook or tool is currently executing, or `undefined`
40
+ * when no plugin is in context.
41
+ */
42
+ export function getCurrentPluginName(): string | undefined {
43
+ return storage.getStore()?.pluginName;
44
+ }
@@ -28,6 +28,7 @@
28
28
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
29
29
 
30
30
  import { credentialKey } from "../../../security/credential-key.js";
31
+ import * as actualMetadataStore from "../../../tools/credentials/metadata-store.js";
31
32
 
32
33
  type MetadataRecord = {
33
34
  credentialId: string;
@@ -80,7 +81,13 @@ mock.module("../../../security/secure-keys.js", () => ({
80
81
  _resetBackend: () => {},
81
82
  }));
82
83
 
84
+ // Spread the real module so every export the reconcile handler's transitive
85
+ // graph links (e.g. getCredentialMetadataById, listCredentialMetadata pulled in
86
+ // via the plugin-api hub) stays present; only the two functions under test are
87
+ // overridden. A partial mock replaces the whole namespace and fails to link the
88
+ // unlisted exports.
83
89
  mock.module("../../../tools/credentials/metadata-store.js", () => ({
90
+ ...actualMetadataStore,
84
91
  getCredentialMetadata: (service: string, field: string) =>
85
92
  metadataStore.get(`${service}:${field}`),
86
93
  upsertCredentialMetadata: (
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { getConfig } from "../config/loader.js";
4
4
  import { PermissionPrompter } from "../permissions/prompter.js";
5
5
  import { RiskLevel } from "../permissions/types.js";
6
+ import { runInPluginContext } from "../plugins/plugin-execution-context.js";
6
7
  import { TokenExpiredError } from "../security/token-manager.js";
7
8
  import {
8
9
  recordToolError,
@@ -19,6 +20,7 @@ import {
19
20
  import { getWorkflowRunManager } from "../workflows/run-manager.js";
20
21
  import { executeWithTimeout, safeTimeoutMs } from "./execution-timeout.js";
21
22
  import { PermissionChecker } from "./permission-checker.js";
23
+ import { getToolOwner } from "./registry.js";
22
24
  import { extractAndSanitize } from "./sensitive-output-placeholders.js";
23
25
  import { applyEdit } from "./shared/filesystem/edit-engine.js";
24
26
  import { sandboxPolicy } from "./shared/filesystem/path-policy.js";
@@ -223,8 +225,19 @@ export class ToolExecutor {
223
225
  const toolTimeoutMs = computePerToolTimeoutMs(name, input);
224
226
  const execContext = context;
225
227
 
228
+ // Mark the owning plugin as in context (via AsyncLocalStorage) so host
229
+ // APIs the tool reaches — e.g. resolveCredential — can scope to it. The
230
+ // context must be established around the `execute()` call itself so the
231
+ // returned promise carries the binding across its awaits. Non-plugin
232
+ // tools (default/skill/mcp/workspace) establish no context.
233
+ const owner = getToolOwner(name);
234
+ const execPromise =
235
+ owner?.kind === "plugin"
236
+ ? runInPluginContext(owner.id, () => tool.execute(input, execContext))
237
+ : tool.execute(input, execContext);
238
+
226
239
  let execResult: ToolExecutionResult = await executeWithTimeout(
227
- tool.execute(input, execContext),
240
+ execPromise,
228
241
  toolTimeoutMs,
229
242
  name,
230
243
  );