@vellumai/credential-executor 0.11.7 → 0.11.8-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.
@@ -216,6 +216,8 @@ export interface StaticCredentialPolicyInput {
216
216
  */
217
217
  export class StaticCredentialMetadataStore {
218
218
  private metadataPath: string;
219
+ /** When set, reads and writes stay in process and do not touch the file. */
220
+ private memory: MetadataFile | null = null;
219
221
 
220
222
  constructor(metadataPath: string) {
221
223
  this.metadataPath = metadataPath;
@@ -224,6 +226,17 @@ export class StaticCredentialMetadataStore {
224
226
  /** Update the metadata file path (primarily for testing). */
225
227
  setPath(path: string): void {
226
228
  this.metadataPath = path;
229
+ this.memory = null;
230
+ }
231
+
232
+ /**
233
+ * Switch to an in-memory backing filled with `records`.
234
+ */
235
+ useMemory(records: StaticCredentialRecord[] = []): void {
236
+ this.memory = {
237
+ version: CURRENT_VERSION,
238
+ credentials: records.map((record) => ({ ...record })),
239
+ };
227
240
  }
228
241
 
229
242
  /** Get the current metadata file path. */
@@ -231,13 +244,28 @@ export class StaticCredentialMetadataStore {
231
244
  return this.metadataPath;
232
245
  }
233
246
 
247
+ private load(): LoadResult {
248
+ if (this.memory) {
249
+ return this.memory;
250
+ }
251
+ return loadFile(this.metadataPath, saveFile);
252
+ }
253
+
254
+ private persist(data: MetadataFile): void {
255
+ if (this.memory) {
256
+ this.memory = data;
257
+ return;
258
+ }
259
+ saveFile(data, this.metadataPath);
260
+ }
261
+
234
262
  /**
235
263
  * Throws if the metadata file has an unrecognized version.
236
264
  * Call this before performing irreversible credential store operations
237
265
  * so the operation fails cleanly before any side effects.
238
266
  */
239
267
  assertWritable(): void {
240
- const result = loadFile(this.metadataPath, saveFile);
268
+ const result = this.load();
241
269
  if (isUnknownVersion(result)) {
242
270
  throw new Error(
243
271
  "Credential metadata file has an unrecognized version; refusing to mutate to avoid data loss"
@@ -254,7 +282,7 @@ export class StaticCredentialMetadataStore {
254
282
  field: string,
255
283
  policy?: StaticCredentialPolicyInput
256
284
  ): StaticCredentialRecord {
257
- const result = loadFile(this.metadataPath, saveFile);
285
+ const result = this.load();
258
286
  if (isUnknownVersion(result)) {
259
287
  throw new Error(
260
288
  "Credential metadata file has an unrecognized version; refusing to mutate to avoid data loss"
@@ -268,12 +296,15 @@ export class StaticCredentialMetadataStore {
268
296
  );
269
297
 
270
298
  if (existing) {
271
- if (policy?.allowedTools !== undefined)
299
+ if (policy?.allowedTools !== undefined) {
272
300
  existing.allowedTools = policy.allowedTools;
273
- if (policy?.allowedDomains !== undefined)
301
+ }
302
+ if (policy?.allowedDomains !== undefined) {
274
303
  existing.allowedDomains = policy.allowedDomains;
275
- if (policy?.usageDescription !== undefined)
304
+ }
305
+ if (policy?.usageDescription !== undefined) {
276
306
  existing.usageDescription = policy.usageDescription;
307
+ }
277
308
  if (policy?.alias !== undefined) {
278
309
  if (policy.alias == null) {
279
310
  delete existing.alias;
@@ -289,7 +320,7 @@ export class StaticCredentialMetadataStore {
289
320
  }
290
321
  }
291
322
  existing.updatedAt = now;
292
- saveFile(data, this.metadataPath);
323
+ this.persist(data);
293
324
  return existing;
294
325
  }
295
326
 
@@ -307,7 +338,7 @@ export class StaticCredentialMetadataStore {
307
338
  };
308
339
 
309
340
  data.credentials.push(record);
310
- saveFile(data, this.metadataPath);
341
+ this.persist(data);
311
342
  return record;
312
343
  }
313
344
 
@@ -318,7 +349,7 @@ export class StaticCredentialMetadataStore {
318
349
  service: string,
319
350
  field: string
320
351
  ): StaticCredentialRecord | undefined {
321
- const result = loadFile(this.metadataPath, saveFile);
352
+ const result = this.load();
322
353
  if (isUnknownVersion(result)) return undefined;
323
354
  return result.credentials.find(
324
355
  (c) => c.service === service && c.field === field
@@ -329,7 +360,7 @@ export class StaticCredentialMetadataStore {
329
360
  * Get metadata for a credential by its opaque ID.
330
361
  */
331
362
  getById(credentialId: string): StaticCredentialRecord | undefined {
332
- const result = loadFile(this.metadataPath, saveFile);
363
+ const result = this.load();
333
364
  if (isUnknownVersion(result)) return undefined;
334
365
  return result.credentials.find((c) => c.credentialId === credentialId);
335
366
  }
@@ -338,7 +369,7 @@ export class StaticCredentialMetadataStore {
338
369
  * List all credential metadata records.
339
370
  */
340
371
  list(): StaticCredentialRecord[] {
341
- const result = loadFile(this.metadataPath, saveFile);
372
+ const result = this.load();
342
373
  if (isUnknownVersion(result)) return [];
343
374
  return result.credentials;
344
375
  }
@@ -347,7 +378,7 @@ export class StaticCredentialMetadataStore {
347
378
  * Delete metadata for a credential.
348
379
  */
349
380
  delete(service: string, field: string): boolean {
350
- const result = loadFile(this.metadataPath, saveFile);
381
+ const result = this.load();
351
382
  if (isUnknownVersion(result)) {
352
383
  throw new Error(
353
384
  "Credential metadata file has an unrecognized version; refusing to mutate to avoid data loss"
@@ -359,7 +390,7 @@ export class StaticCredentialMetadataStore {
359
390
  );
360
391
  if (idx === -1) return false;
361
392
  data.credentials.splice(idx, 1);
362
- saveFile(data, this.metadataPath);
393
+ this.persist(data);
363
394
  return true;
364
395
  }
365
396
  }
@@ -11,6 +11,7 @@
11
11
  "./client-metadata": "./src/client-metadata.ts",
12
12
  "./credential-rpc": "./src/credential-rpc.ts",
13
13
  "./ingress": "./src/ingress.ts",
14
+ "./no-response": "./src/no-response.ts",
14
15
  "./remote-web-pairing": "./src/remote-web-pairing.ts",
15
16
  "./twilio-ingress": "./src/twilio-ingress.ts",
16
17
  "./trust-rules": "./src/trust-rules.ts",
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
66
66
  * That irregularity is why this is stated rather than derived from the key,
67
67
  * and it is stated here because this file already owns what a channel is.
68
68
  *
69
+ * Both senses can hold a user token, which is the sharpest edge. The `slack`
70
+ * integration's persisted token *is* the installer's user token, held on its
71
+ * OAuth connection. `slack_channel` holds an optional `user_token` in the
72
+ * credential store, beside its bot and app tokens. Same words, different
73
+ * homes, and only the second is a credential-store key: a pasted token is
74
+ * always the channel's, because the integration's never leaves the exchange.
75
+ *
69
76
  * Deliberately only the key. What fields each credential requires is declared
70
77
  * once already, per service, in the gateway's credential specs; restating it
71
78
  * here would be a second copy of a different fact.
@@ -27,6 +27,7 @@ export * from "./handles.js";
27
27
  export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
+ export * from "./no-response.js";
30
31
  export * from "./remote-web-pairing.js";
31
32
  export * from "./twilio-ingress.js";
32
33
  export * from "./url-normalization.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The `<no_response/>` sentinel: the cross-service convention for a turn
3
+ * that deliberately produces no user-visible reply.
4
+ *
5
+ * The model emits it, the daemon stamps and strips it, channel delivery
6
+ * suppresses it, and clients hold live-streamed prefixes of it back from
7
+ * display. One definition here keeps every consumer's parsing identical;
8
+ * a hand-rolled copy in any one of them is how the case-sensitivity of a
9
+ * regex drifts.
10
+ */
11
+
12
+ /** Matches a message whose entire content is the sentinel. */
13
+ const NO_RESPONSE_ONLY_RE = /^\s*<no_response\s*\/?>\s*$/i;
14
+
15
+ /**
16
+ * Whether `text` is nothing but the sentinel: the whole reply is a
17
+ * deliberate non-response, as opposed to real content with an inline
18
+ * sentinel mixed in.
19
+ */
20
+ export function isNoResponseOnlyText(text: string): boolean {
21
+ return NO_RESPONSE_ONLY_RE.test(text);
22
+ }
23
+
24
+ /** Matches every sentinel occurrence for stripping it out of mixed content. */
25
+ export const NO_RESPONSE_INLINE_RE = /<no_response\s*\/?>/gi;
26
+
27
+ /**
28
+ * Detection variant without the `g` flag: a `g`-flagged regex is stateful
29
+ * under `.test()` (it resumes from `lastIndex`), so reusing
30
+ * {@link NO_RESPONSE_INLINE_RE} for detection would alternate between
31
+ * matches and misses across calls.
32
+ */
33
+ const NO_RESPONSE_MARKER_RE = new RegExp(NO_RESPONSE_INLINE_RE.source, "i");
34
+
35
+ /** Whether `text` contains the sentinel anywhere. */
36
+ export function containsNoResponseMarker(text: string): boolean {
37
+ return NO_RESPONSE_MARKER_RE.test(text);
38
+ }
39
+
40
+ /** Removes every sentinel occurrence, trimming the leftover whitespace. */
41
+ export function stripNoResponseMarkers(text: string): string {
42
+ return text.replace(NO_RESPONSE_INLINE_RE, "").trim();
43
+ }
44
+
45
+ const NO_RESPONSE_SENTINEL_FORMS = [
46
+ "<no_response/>",
47
+ "<no_response />",
48
+ "<no_response>",
49
+ ] as const;
50
+
51
+ /**
52
+ * Whether `text` could still grow into the sentinel, i.e. it is a leading
53
+ * substring of one of its forms. Holding on these keeps a slowly-streamed
54
+ * `<no_response/>` from surfacing as visible partial content.
55
+ */
56
+ export function isPotentialNoResponsePrefix(text: string): boolean {
57
+ const lower = text.trim().toLowerCase();
58
+ if (lower.length === 0) {
59
+ return false;
60
+ }
61
+ return NO_RESPONSE_SENTINEL_FORMS.some((sentinel) =>
62
+ sentinel.startsWith(lower),
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Whether `text` carries user-visible content worth acting on now. Returns
68
+ * `false` for empty text, the standalone sentinel, and any prefix that could
69
+ * still complete into one; returns `true` once real content remains after
70
+ * stripping inline sentinels.
71
+ */
72
+ export function hasDeliverableAssistantText(text: string): boolean {
73
+ const trimmed = text.trim();
74
+ if (trimmed.length === 0) {
75
+ return false;
76
+ }
77
+ if (NO_RESPONSE_ONLY_RE.test(trimmed)) {
78
+ return false;
79
+ }
80
+ if (isPotentialNoResponsePrefix(trimmed)) {
81
+ return false;
82
+ }
83
+ return stripNoResponseMarkers(trimmed).length > 0;
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.11.7",
3
+ "version": "0.11.8-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,11 +1,13 @@
1
- import { mkdtempSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
6
 
7
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
7
8
  import type { CredentialRecord } from "@vellumai/service-contracts/credential-rpc";
8
9
 
10
+ import { importWorkspaceMetadataMigration } from "../migrations/003-import-workspace-metadata.js";
9
11
  import {
10
12
  CesMetadataStore,
11
13
  getMetadataPath,
@@ -89,3 +91,122 @@ describe("parseCredentialAccount", () => {
89
91
  expect(parseCredentialAccount("oauth/google/access")).toBeUndefined();
90
92
  });
91
93
  });
94
+
95
+ describe("003-import-workspace-metadata", () => {
96
+ test("imports leftover workspace metadata without deleting the file", async () => {
97
+ const workspace = mkdtempSync(join(tmpdir(), "ces-ws-"));
98
+ const cesData = mkdtempSync(join(tmpdir(), "ces-data-"));
99
+ const leftoverPath = join(workspace, "data", "credentials", "metadata.json");
100
+ mkdirSync(join(workspace, "data", "credentials"), { recursive: true });
101
+ writeFileSync(
102
+ leftoverPath,
103
+ JSON.stringify({
104
+ version: 5,
105
+ credentials: [
106
+ makeRecord("vercel", "api_token", { allowedTools: ["publish_page"] }),
107
+ ],
108
+ }),
109
+ );
110
+
111
+ const prevWorkspace = process.env.VELLUM_WORKSPACE_DIR;
112
+ const prevMode = process.env.CES_MODE;
113
+ const prevData = process.env.CES_DATA_DIR;
114
+ process.env.VELLUM_WORKSPACE_DIR = workspace;
115
+ process.env.CES_MODE = "managed";
116
+ process.env.CES_DATA_DIR = cesData;
117
+
118
+ const unusedBackend: SecureKeyBackend = {
119
+ get: async () => undefined,
120
+ set: async () => true,
121
+ delete: async () => "not-found",
122
+ list: async () => [],
123
+ };
124
+
125
+ try {
126
+ await importWorkspaceMetadataMigration.run(unusedBackend);
127
+ const store = new CesMetadataStore(getMetadataPath(cesData));
128
+ const imported = store.getByAccount("credential/vercel/api_token");
129
+ expect(imported?.allowedTools).toEqual(["publish_page"]);
130
+ expect(imported?.credentialId).toBe("id-vercel-api_token");
131
+ expect(existsSync(leftoverPath)).toBe(true);
132
+ } finally {
133
+ if (prevWorkspace === undefined) {
134
+ delete process.env.VELLUM_WORKSPACE_DIR;
135
+ } else {
136
+ process.env.VELLUM_WORKSPACE_DIR = prevWorkspace;
137
+ }
138
+ if (prevMode === undefined) {
139
+ delete process.env.CES_MODE;
140
+ } else {
141
+ process.env.CES_MODE = prevMode;
142
+ }
143
+ if (prevData === undefined) {
144
+ delete process.env.CES_DATA_DIR;
145
+ } else {
146
+ process.env.CES_DATA_DIR = prevData;
147
+ }
148
+ }
149
+ });
150
+
151
+ test("keeps an existing CES record instead of overwriting it", async () => {
152
+ const workspace = mkdtempSync(join(tmpdir(), "ces-ws-"));
153
+ const cesData = mkdtempSync(join(tmpdir(), "ces-data-"));
154
+ const leftoverPath = join(workspace, "data", "credentials", "metadata.json");
155
+ mkdirSync(join(workspace, "data", "credentials"), { recursive: true });
156
+ writeFileSync(
157
+ leftoverPath,
158
+ JSON.stringify({
159
+ version: 5,
160
+ credentials: [
161
+ makeRecord("vercel", "api_token", { allowedTools: ["bash"] }),
162
+ ],
163
+ }),
164
+ );
165
+ const store = new CesMetadataStore(getMetadataPath(cesData));
166
+ store.setByAccount(
167
+ "credential/vercel/api_token",
168
+ makeRecord("vercel", "api_token", {
169
+ credentialId: "id-ces-newer",
170
+ allowedTools: ["publish_page"],
171
+ }),
172
+ );
173
+
174
+ const prevWorkspace = process.env.VELLUM_WORKSPACE_DIR;
175
+ const prevMode = process.env.CES_MODE;
176
+ const prevData = process.env.CES_DATA_DIR;
177
+ process.env.VELLUM_WORKSPACE_DIR = workspace;
178
+ process.env.CES_MODE = "managed";
179
+ process.env.CES_DATA_DIR = cesData;
180
+
181
+ const unusedBackend: SecureKeyBackend = {
182
+ get: async () => undefined,
183
+ set: async () => true,
184
+ delete: async () => "not-found",
185
+ list: async () => [],
186
+ };
187
+
188
+ try {
189
+ await importWorkspaceMetadataMigration.run(unusedBackend);
190
+ const kept = store.getByAccount("credential/vercel/api_token");
191
+ expect(kept?.credentialId).toBe("id-ces-newer");
192
+ expect(kept?.allowedTools).toEqual(["publish_page"]);
193
+ expect(existsSync(leftoverPath)).toBe(true);
194
+ } finally {
195
+ if (prevWorkspace === undefined) {
196
+ delete process.env.VELLUM_WORKSPACE_DIR;
197
+ } else {
198
+ process.env.VELLUM_WORKSPACE_DIR = prevWorkspace;
199
+ }
200
+ if (prevMode === undefined) {
201
+ delete process.env.CES_MODE;
202
+ } else {
203
+ process.env.CES_MODE = prevMode;
204
+ }
205
+ if (prevData === undefined) {
206
+ delete process.env.CES_DATA_DIR;
207
+ } else {
208
+ process.env.CES_DATA_DIR = prevData;
209
+ }
210
+ }
211
+ });
212
+ });
@@ -177,6 +177,7 @@ export async function handleLogExportRoute(
177
177
  const proc = spawnSync("tar", ["czf", "-", "-C", staging, "."], {
178
178
  maxBuffer: MAX_LOG_BYTES * 2, // allow headroom for tar overhead
179
179
  timeout: 30_000,
180
+ windowsHide: true,
180
181
  });
181
182
 
182
183
  if (proc.status !== 0) {
@@ -0,0 +1,76 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
5
+
6
+ import { getLogger } from "../logger.js";
7
+ import { getCesDataRoot } from "../paths.js";
8
+ import {
9
+ accountForRecord,
10
+ CesMetadataStore,
11
+ getMetadataPath,
12
+ } from "../records/metadata-store.js";
13
+ import type { CesMigration } from "./types.js";
14
+
15
+ function leftoverWorkspaceMetadataPath(
16
+ workspaceDir: string | undefined,
17
+ ): string | undefined {
18
+ if (!workspaceDir || workspaceDir.trim() === "") {
19
+ return undefined;
20
+ }
21
+ return join(workspaceDir, "data", "credentials", "metadata.json");
22
+ }
23
+
24
+ const log = getLogger("ces-migrations");
25
+
26
+ /**
27
+ * Copy workspace `metadata.json` catalog rows into the CES metadata store.
28
+ *
29
+ * CES does not delete the workspace file (the workspace volume is
30
+ * read-only in managed mode).
31
+ */
32
+ export const importWorkspaceMetadataMigration: CesMigration = {
33
+ id: "003-import-workspace-metadata",
34
+ description:
35
+ "Import workspace credential metadata.json into the CES metadata store",
36
+
37
+ async run(_backend: SecureKeyBackend): Promise<void> {
38
+ const workspaceDir = process.env["VELLUM_WORKSPACE_DIR"]?.trim();
39
+ const leftoverPath = leftoverWorkspaceMetadataPath(workspaceDir);
40
+ if (!leftoverPath || !existsSync(leftoverPath)) {
41
+ log.info("CES metadata import: no workspace metadata.json; skipping");
42
+ return;
43
+ }
44
+
45
+ const source = new CesMetadataStore(leftoverPath);
46
+ const records = source.list();
47
+ if (records.length === 0) {
48
+ log.info("CES metadata import: workspace metadata.json has no rows");
49
+ return;
50
+ }
51
+
52
+ const store = new CesMetadataStore(getMetadataPath(getCesDataRoot()));
53
+ let imported = 0;
54
+ let skipped = 0;
55
+ for (const { record } of records) {
56
+ const account = accountForRecord(record);
57
+ const existing = store.getByAccount(account);
58
+ if (existing) {
59
+ skipped += 1;
60
+ continue;
61
+ }
62
+ const ok = store.setByAccount(account, record);
63
+ if (ok) {
64
+ imported += 1;
65
+ }
66
+ }
67
+ log.info(
68
+ { imported, skipped, total: records.length },
69
+ "CES metadata import from workspace metadata.json complete",
70
+ );
71
+ },
72
+
73
+ async down(_backend: SecureKeyBackend): Promise<void> {
74
+ // Forward-only: records remain in CES.
75
+ },
76
+ };
@@ -1,5 +1,6 @@
1
- import { apiKeyToCredentialsMigration } from "./002-api-keys-to-credentials.js";
2
1
  import { noOpMigration } from "./001-no-op.js";
2
+ import { apiKeyToCredentialsMigration } from "./002-api-keys-to-credentials.js";
3
+ import { importWorkspaceMetadataMigration } from "./003-import-workspace-metadata.js";
3
4
  import type { CesMigration } from "./types.js";
4
5
 
5
6
  /**
@@ -12,4 +13,5 @@ import type { CesMigration } from "./types.js";
12
13
  export const CES_MIGRATIONS: CesMigration[] = [
13
14
  noOpMigration,
14
15
  apiKeyToCredentialsMigration,
16
+ importWorkspaceMetadataMigration,
15
17
  ];