@vellumai/vellum-gateway 0.5.7 → 0.5.9
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 +1 -1
- package/src/__tests__/guardian-init-lockfile.test.ts +217 -4
- package/src/__tests__/slack-errors.test.ts +85 -1
- package/src/credential-reader.ts +4 -4
- package/src/credential-watcher.ts +28 -7
- package/src/feature-flag-registry.json +1 -1
- package/src/http/routes/channel-verification-session-proxy.ts +141 -24
- package/src/http/routes/slack-deliver.ts +114 -5
- package/src/slack/block-kit-builder.test.ts +10 -3
- package/src/slack/block-kit-builder.ts +3 -0
- package/src/slack/errors.ts +53 -0
- package/src/slack/socket-mode.ts +27 -58
- package/src/telegram/api.ts +40 -18
- package/src/whatsapp/api.ts +26 -12
- package/src/__tests__/slack-app-home.test.ts +0 -155
- package/src/slack/app-home.ts +0 -120
package/package.json
CHANGED
|
@@ -20,6 +20,8 @@ mock.module("../fetch.js", () => ({
|
|
|
20
20
|
|
|
21
21
|
let lockFileExists = false;
|
|
22
22
|
let writtenLockFiles: string[] = [];
|
|
23
|
+
let consumedSecretsContent: string | null = null;
|
|
24
|
+
let writtenConsumedFiles: string[] = [];
|
|
23
25
|
|
|
24
26
|
mock.module("node:fs", () => ({
|
|
25
27
|
...actualFs,
|
|
@@ -27,8 +29,20 @@ mock.module("node:fs", () => ({
|
|
|
27
29
|
if (typeof p === "string" && p.endsWith("guardian-init.lock")) {
|
|
28
30
|
return lockFileExists;
|
|
29
31
|
}
|
|
32
|
+
if (typeof p === "string" && p.endsWith("guardian-init-consumed.json")) {
|
|
33
|
+
return consumedSecretsContent !== null;
|
|
34
|
+
}
|
|
30
35
|
return actualFs.existsSync(p);
|
|
31
36
|
},
|
|
37
|
+
readFileSync: (p: string, encoding?: BufferEncoding) => {
|
|
38
|
+
if (typeof p === "string" && p.endsWith("guardian-init-consumed.json")) {
|
|
39
|
+
if (consumedSecretsContent === null) {
|
|
40
|
+
throw new Error("ENOENT");
|
|
41
|
+
}
|
|
42
|
+
return consumedSecretsContent;
|
|
43
|
+
}
|
|
44
|
+
return actualFs.readFileSync(p, encoding as BufferEncoding);
|
|
45
|
+
},
|
|
32
46
|
writeFileSync: (
|
|
33
47
|
p: string,
|
|
34
48
|
data: string | NodeJS.ArrayBufferView,
|
|
@@ -39,6 +53,11 @@ mock.module("node:fs", () => ({
|
|
|
39
53
|
lockFileExists = true;
|
|
40
54
|
return;
|
|
41
55
|
}
|
|
56
|
+
if (typeof p === "string" && p.endsWith("guardian-init-consumed.json")) {
|
|
57
|
+
consumedSecretsContent = String(data);
|
|
58
|
+
writtenConsumedFiles.push(p);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
42
61
|
return actualFs.writeFileSync(p, data, options);
|
|
43
62
|
},
|
|
44
63
|
}));
|
|
@@ -78,13 +97,16 @@ afterEach(() => {
|
|
|
78
97
|
fetchMock = mock(async () => new Response());
|
|
79
98
|
lockFileExists = false;
|
|
80
99
|
writtenLockFiles = [];
|
|
100
|
+
consumedSecretsContent = null;
|
|
101
|
+
writtenConsumedFiles = [];
|
|
81
102
|
});
|
|
82
103
|
|
|
83
104
|
describe("guardian/init bootstrap secret", () => {
|
|
84
105
|
test("rejects requests without secret when GUARDIAN_BOOTSTRAP_SECRET is set", async () => {
|
|
85
106
|
process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
|
|
86
107
|
try {
|
|
87
|
-
const handler =
|
|
108
|
+
const handler =
|
|
109
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
88
110
|
const res = await handler.handleGuardianInit(
|
|
89
111
|
new Request("http://localhost:7830/v1/guardian/init", {
|
|
90
112
|
method: "POST",
|
|
@@ -104,7 +126,8 @@ describe("guardian/init bootstrap secret", () => {
|
|
|
104
126
|
test("rejects requests with wrong secret", async () => {
|
|
105
127
|
process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
|
|
106
128
|
try {
|
|
107
|
-
const handler =
|
|
129
|
+
const handler =
|
|
130
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
108
131
|
const res = await handler.handleGuardianInit(
|
|
109
132
|
new Request("http://localhost:7830/v1/guardian/init", {
|
|
110
133
|
method: "POST",
|
|
@@ -124,7 +147,7 @@ describe("guardian/init bootstrap secret", () => {
|
|
|
124
147
|
}
|
|
125
148
|
});
|
|
126
149
|
|
|
127
|
-
test("accepts requests with correct secret", async () => {
|
|
150
|
+
test("accepts requests with correct secret and writes lock for single secret", async () => {
|
|
128
151
|
process.env.GUARDIAN_BOOTSTRAP_SECRET = "test-secret-abc123";
|
|
129
152
|
fetchMock = mock(async () => {
|
|
130
153
|
return new Response(
|
|
@@ -134,7 +157,8 @@ describe("guardian/init bootstrap secret", () => {
|
|
|
134
157
|
});
|
|
135
158
|
|
|
136
159
|
try {
|
|
137
|
-
const handler =
|
|
160
|
+
const handler =
|
|
161
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
138
162
|
const res = await handler.handleGuardianInit(
|
|
139
163
|
new Request("http://localhost:7830/v1/guardian/init", {
|
|
140
164
|
method: "POST",
|
|
@@ -147,6 +171,9 @@ describe("guardian/init bootstrap secret", () => {
|
|
|
147
171
|
);
|
|
148
172
|
|
|
149
173
|
expect(res.status).toBe(200);
|
|
174
|
+
// Single secret: consumed file written and lock file created immediately
|
|
175
|
+
expect(writtenConsumedFiles.length).toBe(1);
|
|
176
|
+
expect(writtenLockFiles.length).toBe(1);
|
|
150
177
|
} finally {
|
|
151
178
|
delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
|
|
152
179
|
}
|
|
@@ -291,3 +318,189 @@ describe("guardian/init one-time-use lockfile", () => {
|
|
|
291
318
|
expect(writtenLockFiles.length).toBe(0);
|
|
292
319
|
});
|
|
293
320
|
});
|
|
321
|
+
|
|
322
|
+
describe("guardian/init multi-secret consumption tracking", () => {
|
|
323
|
+
const SECRET_A = "secret-laptop-aaa";
|
|
324
|
+
const SECRET_B = "secret-remote-bbb";
|
|
325
|
+
|
|
326
|
+
function makeInitRequest(secret?: string): Request {
|
|
327
|
+
const headers: Record<string, string> = {
|
|
328
|
+
"Content-Type": "application/json",
|
|
329
|
+
};
|
|
330
|
+
if (secret) {
|
|
331
|
+
headers["x-bootstrap-secret"] = secret;
|
|
332
|
+
}
|
|
333
|
+
return new Request("http://localhost:7830/v1/guardian/init", {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers,
|
|
336
|
+
body: JSON.stringify({
|
|
337
|
+
platform: "cli",
|
|
338
|
+
deviceId: `device-${secret ?? "none"}`,
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
test("first secret is consumed but lock is deferred until all secrets used", async () => {
|
|
344
|
+
process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
|
|
345
|
+
fetchMock = mock(async () => {
|
|
346
|
+
return new Response(
|
|
347
|
+
JSON.stringify({ accessToken: "jwt-a", refreshToken: "rt-a" }),
|
|
348
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const handler =
|
|
354
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
355
|
+
const res = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
356
|
+
|
|
357
|
+
expect(res.status).toBe(200);
|
|
358
|
+
// Consumed file written, but lock file NOT yet created
|
|
359
|
+
expect(writtenConsumedFiles.length).toBe(1);
|
|
360
|
+
expect(writtenLockFiles.length).toBe(0);
|
|
361
|
+
} finally {
|
|
362
|
+
delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("lock file written after all secrets consumed", async () => {
|
|
367
|
+
process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
|
|
368
|
+
fetchMock = mock(async () => {
|
|
369
|
+
return new Response(
|
|
370
|
+
JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
|
|
371
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const handler =
|
|
377
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
378
|
+
|
|
379
|
+
// First secret
|
|
380
|
+
const res1 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
381
|
+
expect(res1.status).toBe(200);
|
|
382
|
+
expect(writtenLockFiles.length).toBe(0);
|
|
383
|
+
|
|
384
|
+
// Second secret
|
|
385
|
+
const res2 = await handler.handleGuardianInit(makeInitRequest(SECRET_B));
|
|
386
|
+
expect(res2.status).toBe(200);
|
|
387
|
+
expect(writtenLockFiles.length).toBe(1);
|
|
388
|
+
} finally {
|
|
389
|
+
delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("reusing a consumed secret is rejected", async () => {
|
|
394
|
+
process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
|
|
395
|
+
fetchMock = mock(async () => {
|
|
396
|
+
return new Response(
|
|
397
|
+
JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
|
|
398
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const handler =
|
|
404
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
405
|
+
|
|
406
|
+
// Consume SECRET_A
|
|
407
|
+
const res1 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
408
|
+
expect(res1.status).toBe(200);
|
|
409
|
+
|
|
410
|
+
// Try to reuse SECRET_A
|
|
411
|
+
const res2 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
412
|
+
expect(res2.status).toBe(403);
|
|
413
|
+
const body = await res2.json();
|
|
414
|
+
expect(body.error).toBe("Bootstrap secret already used");
|
|
415
|
+
} finally {
|
|
416
|
+
delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("all secrets rejected after full consumption and lock", async () => {
|
|
421
|
+
process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
|
|
422
|
+
fetchMock = mock(async () => {
|
|
423
|
+
return new Response(
|
|
424
|
+
JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
|
|
425
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const handler =
|
|
431
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
432
|
+
|
|
433
|
+
// Consume both
|
|
434
|
+
await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
435
|
+
await handler.handleGuardianInit(makeInitRequest(SECRET_B));
|
|
436
|
+
expect(writtenLockFiles.length).toBe(1);
|
|
437
|
+
|
|
438
|
+
// Any further attempt is rejected (lock file exists)
|
|
439
|
+
const res = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
440
|
+
expect(res.status).toBe(403);
|
|
441
|
+
const body = await res.json();
|
|
442
|
+
expect(body.error).toContain("already");
|
|
443
|
+
} finally {
|
|
444
|
+
delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("concurrent requests with same secret rejected by in-flight guard", async () => {
|
|
449
|
+
process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
|
|
450
|
+
let resolveProxy: (() => void) | undefined;
|
|
451
|
+
fetchMock = mock(async () => {
|
|
452
|
+
await new Promise<void>((resolve) => {
|
|
453
|
+
resolveProxy = resolve;
|
|
454
|
+
});
|
|
455
|
+
return new Response(
|
|
456
|
+
JSON.stringify({ accessToken: "jwt", refreshToken: "rt" }),
|
|
457
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const handler =
|
|
463
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
464
|
+
|
|
465
|
+
// GIVEN a first request with SECRET_A is in flight
|
|
466
|
+
const p1 = handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
467
|
+
|
|
468
|
+
// WHEN a second request with the same SECRET_A arrives concurrently
|
|
469
|
+
const res2 = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
470
|
+
|
|
471
|
+
// THEN the second request is rejected
|
|
472
|
+
expect(res2.status).toBe(403);
|
|
473
|
+
const body = await res2.json();
|
|
474
|
+
expect(body.error).toBe("Bootstrap secret already used");
|
|
475
|
+
|
|
476
|
+
// AND the first request completes successfully once resolved
|
|
477
|
+
resolveProxy!();
|
|
478
|
+
const res1 = await p1;
|
|
479
|
+
expect(res1.status).toBe(200);
|
|
480
|
+
} finally {
|
|
481
|
+
delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("consumed secret not recorded when upstream fails", async () => {
|
|
486
|
+
process.env.GUARDIAN_BOOTSTRAP_SECRET = `${SECRET_A},${SECRET_B}`;
|
|
487
|
+
fetchMock = mock(async () => {
|
|
488
|
+
return new Response(JSON.stringify({ error: "Internal error" }), {
|
|
489
|
+
status: 500,
|
|
490
|
+
headers: { "content-type": "application/json" },
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const handler =
|
|
496
|
+
createChannelVerificationSessionProxyHandler(makeConfig());
|
|
497
|
+
const res = await handler.handleGuardianInit(makeInitRequest(SECRET_A));
|
|
498
|
+
|
|
499
|
+
expect(res.status).toBe(500);
|
|
500
|
+
expect(writtenConsumedFiles.length).toBe(0);
|
|
501
|
+
expect(writtenLockFiles.length).toBe(0);
|
|
502
|
+
} finally {
|
|
503
|
+
delete process.env.GUARDIAN_BOOTSTRAP_SECRET;
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
classifySlackError,
|
|
4
|
+
isRetryable,
|
|
5
|
+
getUserMessage,
|
|
6
|
+
} from "../slack/errors.js";
|
|
3
7
|
|
|
4
8
|
describe("classifySlackError", () => {
|
|
5
9
|
test("classifies auth errors", () => {
|
|
@@ -75,3 +79,83 @@ describe("isRetryable", () => {
|
|
|
75
79
|
expect(isRetryable("channel_not_found")).toBe(false);
|
|
76
80
|
});
|
|
77
81
|
});
|
|
82
|
+
|
|
83
|
+
describe("getUserMessage", () => {
|
|
84
|
+
test("returns specific message for channel_not_found", () => {
|
|
85
|
+
expect(getUserMessage("channel_not_found")).toBe(
|
|
86
|
+
"I can't send messages to this channel. Please re-add me to the channel.",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("returns specific message for not_in_channel", () => {
|
|
91
|
+
expect(getUserMessage("not_in_channel")).toBe(
|
|
92
|
+
"I need to be invited to this channel first. Please add me to the channel.",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns specific message for missing_scope", () => {
|
|
97
|
+
expect(getUserMessage("missing_scope")).toBe(
|
|
98
|
+
"I don't have the required permissions. Please re-install the Slack app with the necessary scopes.",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns specific message for token_revoked", () => {
|
|
103
|
+
expect(getUserMessage("token_revoked")).toBe(
|
|
104
|
+
"My Slack connection has expired. Please re-configure the Slack integration.",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns specific message for token_expired", () => {
|
|
109
|
+
expect(getUserMessage("token_expired")).toBe(
|
|
110
|
+
"My Slack connection has expired. Please re-configure the Slack integration.",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("returns specific message for invalid_auth", () => {
|
|
115
|
+
expect(getUserMessage("invalid_auth")).toBe(
|
|
116
|
+
"My Slack connection has expired. Please re-configure the Slack integration.",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns specific message for is_archived", () => {
|
|
121
|
+
expect(getUserMessage("is_archived")).toBe(
|
|
122
|
+
"This channel has been archived. Please unarchive it or use a different channel.",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("returns specific message for cannot_dm_bot", () => {
|
|
127
|
+
expect(getUserMessage("cannot_dm_bot")).toBe(
|
|
128
|
+
"I can't send direct messages to other bots.",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("falls back to category message for auth errors without specific override", () => {
|
|
133
|
+
expect(getUserMessage("not_authed")).toBe(
|
|
134
|
+
"My Slack connection has expired. Please re-configure the Slack integration.",
|
|
135
|
+
);
|
|
136
|
+
expect(getUserMessage("account_inactive")).toBe(
|
|
137
|
+
"My Slack connection has expired. Please re-configure the Slack integration.",
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("falls back to category message for permission errors without specific override", () => {
|
|
142
|
+
expect(getUserMessage("ekm_access_denied")).toBe(
|
|
143
|
+
"I don't have the required permissions for this channel. Please check my access.",
|
|
144
|
+
);
|
|
145
|
+
expect(getUserMessage("restricted_action")).toBe(
|
|
146
|
+
"I don't have the required permissions for this channel. Please check my access.",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("returns undefined for unknown errors", () => {
|
|
151
|
+
expect(getUserMessage("some_new_error")).toBeUndefined();
|
|
152
|
+
expect(getUserMessage(undefined)).toBeUndefined();
|
|
153
|
+
expect(getUserMessage("")).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("returns message for rate_limit errors", () => {
|
|
157
|
+
expect(getUserMessage("rate_limited")).toBe(
|
|
158
|
+
"Slack rate limit reached. Please try again in a moment.",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/credential-reader.ts
CHANGED
|
@@ -142,12 +142,12 @@ export function getRootDir(): string {
|
|
|
142
142
|
/**
|
|
143
143
|
* Returns the workspace root for user-facing state.
|
|
144
144
|
*
|
|
145
|
-
* When
|
|
146
|
-
*
|
|
147
|
-
*
|
|
145
|
+
* When VELLUM_WORKSPACE_DIR is set, returns that value (used in containerized
|
|
146
|
+
* deployments where the workspace is a separate volume). Otherwise falls back
|
|
147
|
+
* to ~/.vellum/workspace.
|
|
148
148
|
*/
|
|
149
149
|
export function getWorkspaceDir(): string {
|
|
150
|
-
const override = process.env.
|
|
150
|
+
const override = process.env.VELLUM_WORKSPACE_DIR?.trim();
|
|
151
151
|
if (override) return override;
|
|
152
152
|
return join(getRootDir(), "workspace");
|
|
153
153
|
}
|
|
@@ -73,15 +73,27 @@ export class CredentialWatcher {
|
|
|
73
73
|
// Watch the protected directory for store.key changes so that
|
|
74
74
|
// creating or restoring the v2 store key triggers a credential reload.
|
|
75
75
|
this.startWatcher(protectedDir, "store.key");
|
|
76
|
+
|
|
77
|
+
// Watch keys.enc for credential writes. When credentials are re-saved
|
|
78
|
+
// with the same values (e.g. in-chat credential_store re-entering
|
|
79
|
+
// existing tokens), the serialized credential values won't change —
|
|
80
|
+
// but the encrypted ciphertext will (new IV). Force a full reload so
|
|
81
|
+
// channel listeners restart even when the plaintext values match.
|
|
82
|
+
this.startWatcher(protectedDir, "keys.enc", { forceChanged: true });
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
private startWatcher(
|
|
85
|
+
private startWatcher(
|
|
86
|
+
dir: string,
|
|
87
|
+
targetFilename: string,
|
|
88
|
+
opts?: { forceChanged?: boolean },
|
|
89
|
+
): void {
|
|
90
|
+
const forceChanged = opts?.forceChanged ?? false;
|
|
79
91
|
try {
|
|
80
92
|
const watcher = watch(dir, { persistent: false }, (_event, filename) => {
|
|
81
93
|
if (filename && filename !== targetFilename) {
|
|
82
94
|
return;
|
|
83
95
|
}
|
|
84
|
-
this.scheduleCheck();
|
|
96
|
+
this.scheduleCheck(forceChanged);
|
|
85
97
|
});
|
|
86
98
|
this.watchers.push(watcher);
|
|
87
99
|
|
|
@@ -106,21 +118,28 @@ export class CredentialWatcher {
|
|
|
106
118
|
this.watchers = [];
|
|
107
119
|
}
|
|
108
120
|
|
|
109
|
-
|
|
121
|
+
/** Whether the next scheduled poll should treat all services as changed. */
|
|
122
|
+
private pendingForceChanged = false;
|
|
123
|
+
|
|
124
|
+
private scheduleCheck(forceChanged = false): void {
|
|
125
|
+
if (forceChanged) this.pendingForceChanged = true;
|
|
110
126
|
if (this.debounceTimer) {
|
|
111
127
|
clearTimeout(this.debounceTimer);
|
|
112
128
|
}
|
|
113
129
|
this.debounceTimer = setTimeout(() => {
|
|
114
130
|
this.debounceTimer = null;
|
|
115
|
-
|
|
131
|
+
const force = this.pendingForceChanged;
|
|
132
|
+
this.pendingForceChanged = false;
|
|
133
|
+
void this.pollOnce(force);
|
|
116
134
|
}, DEBOUNCE_MS);
|
|
117
135
|
}
|
|
118
136
|
|
|
119
|
-
private async pollOnce(): Promise<void> {
|
|
137
|
+
private async pollOnce(forceChanged = false): Promise<void> {
|
|
120
138
|
if (this.polling) {
|
|
121
139
|
// A poll is already in flight — flag that another round is needed
|
|
122
140
|
// so credential updates arriving mid-poll aren't silently dropped.
|
|
123
141
|
this.pendingPoll = true;
|
|
142
|
+
if (forceChanged) this.pendingForceChanged = true;
|
|
124
143
|
return;
|
|
125
144
|
}
|
|
126
145
|
this.polling = true;
|
|
@@ -141,7 +160,7 @@ export class CredentialWatcher {
|
|
|
141
160
|
for (const [name, { creds }] of Object.entries(services)) {
|
|
142
161
|
const newVal = creds ? JSON.stringify(creds) : undefined;
|
|
143
162
|
const oldVal = this.lastSerialized.get(name);
|
|
144
|
-
if (newVal !== oldVal) {
|
|
163
|
+
if (newVal !== oldVal || (forceChanged && newVal !== undefined)) {
|
|
145
164
|
changedServices.add(name);
|
|
146
165
|
if (newVal !== undefined) {
|
|
147
166
|
this.lastSerialized.set(name, newVal);
|
|
@@ -167,7 +186,9 @@ export class CredentialWatcher {
|
|
|
167
186
|
this.polling = false;
|
|
168
187
|
if (this.pendingPoll) {
|
|
169
188
|
this.pendingPoll = false;
|
|
170
|
-
|
|
189
|
+
const force = this.pendingForceChanged;
|
|
190
|
+
this.pendingForceChanged = false;
|
|
191
|
+
void this.pollOnce(force);
|
|
171
192
|
}
|
|
172
193
|
}
|
|
173
194
|
}
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"key": "feature_flags.ces-managed-sidecar.enabled",
|
|
112
112
|
"label": "CES Managed Sidecar Transport",
|
|
113
113
|
"description": "Use managed sidecar transport for CES communication when running in a containerized environment",
|
|
114
|
-
"defaultEnabled":
|
|
114
|
+
"defaultEnabled": true
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"id": "ces-credential-backend",
|