@vellumai/vellum-gateway 0.4.56 → 0.5.0
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 +2 -2
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +1 -1
- package/src/__tests__/credential-reader.test.ts +107 -14
- package/src/__tests__/credential-watcher.test.ts +57 -1
- package/src/__tests__/sleep-wake-detector.test.ts +83 -0
- package/src/config.ts +4 -2
- package/src/credential-reader.ts +55 -11
- package/src/credential-watcher.ts +35 -26
- package/src/feature-flag-registry.json +33 -17
- package/src/index.ts +93 -0
- package/src/schema.ts +195 -0
- package/src/slack/socket-mode.ts +110 -16
- package/src/sleep-wake-detector.ts +44 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -215,13 +215,13 @@ Channel readiness endpoints are exposed directly by the gateway and forwarded to
|
|
|
215
215
|
|
|
216
216
|
### Channel Binding Lifecycle (Lane Separation)
|
|
217
217
|
|
|
218
|
-
Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop conversation list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering
|
|
218
|
+
Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop conversation list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering conversations during desktop conversation restoration.
|
|
219
219
|
|
|
220
220
|
Channel bindings follow a three-phase lifecycle:
|
|
221
221
|
|
|
222
222
|
1. **Bind** — An inbound message from an external channel (e.g., Telegram chat) arrives at the gateway, which normalizes it and forwards it to the runtime's `/v1/channels/inbound` endpoint. The runtime creates or reuses a conversation, establishing the channel binding (`sourceChannel` metadata on the conversation).
|
|
223
223
|
|
|
224
|
-
2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound
|
|
224
|
+
2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
|
|
225
225
|
|
|
226
226
|
3. **Rebind** — If a message arrives on an external chat whose conversation was previously deleted, the channel inbound handler treats it as a new conversation and establishes a fresh binding. The external chat ID is reused, but the conversation is new.
|
|
227
227
|
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ describe("config: hardcoded defaults", () => {
|
|
|
13
13
|
telegram: 20 * 1024 * 1024,
|
|
14
14
|
slack: 100 * 1024 * 1024,
|
|
15
15
|
whatsapp: 16 * 1024 * 1024,
|
|
16
|
-
default:
|
|
16
|
+
default: 100 * 1024 * 1024,
|
|
17
17
|
});
|
|
18
18
|
expect(config.maxAttachmentConcurrency).toBe(3);
|
|
19
19
|
expect(config.runtimeProxyEnabled).toBe(false);
|
|
@@ -78,24 +78,16 @@ function getMachineEntropy(): string {
|
|
|
78
78
|
return parts.join(":");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const salt = randomBytes(16);
|
|
86
|
-
const key = pbkdf2Sync(
|
|
87
|
-
getMachineEntropy(),
|
|
88
|
-
salt,
|
|
89
|
-
PBKDF2_ITERATIONS,
|
|
90
|
-
KEY_LENGTH,
|
|
91
|
-
"sha512",
|
|
92
|
-
);
|
|
81
|
+
function encryptEntries(
|
|
82
|
+
entries: Record<string, string>,
|
|
83
|
+
key: Buffer,
|
|
84
|
+
): Record<string, { iv: string; tag: string; data: string }> {
|
|
93
85
|
const encryptedEntries: Record<
|
|
94
86
|
string,
|
|
95
87
|
{ iv: string; tag: string; data: string }
|
|
96
88
|
> = {};
|
|
97
89
|
for (const [account, value] of Object.entries(entries)) {
|
|
98
|
-
const iv = randomBytes(
|
|
90
|
+
const iv = randomBytes(16);
|
|
99
91
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
|
100
92
|
authTagLength: AUTH_TAG_LENGTH,
|
|
101
93
|
});
|
|
@@ -110,15 +102,48 @@ function writeEncryptedStore(entries: Record<string, string>): void {
|
|
|
110
102
|
data: encrypted.toString("hex"),
|
|
111
103
|
};
|
|
112
104
|
}
|
|
105
|
+
return encryptedEntries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeEncryptedStore(entries: Record<string, string>): void {
|
|
109
|
+
const storePath = join(testDir, ".vellum", "protected", "keys.enc");
|
|
110
|
+
mkdirSync(join(testDir, ".vellum", "protected"), { recursive: true });
|
|
111
|
+
|
|
112
|
+
const salt = randomBytes(16);
|
|
113
|
+
const key = pbkdf2Sync(
|
|
114
|
+
getMachineEntropy(),
|
|
115
|
+
salt,
|
|
116
|
+
PBKDF2_ITERATIONS,
|
|
117
|
+
KEY_LENGTH,
|
|
118
|
+
"sha512",
|
|
119
|
+
);
|
|
113
120
|
|
|
114
121
|
const store = {
|
|
115
122
|
version: 1,
|
|
116
123
|
salt: salt.toString("hex"),
|
|
117
|
-
entries:
|
|
124
|
+
entries: encryptEntries(entries, key),
|
|
118
125
|
};
|
|
119
126
|
writeFileSync(storePath, JSON.stringify(store));
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Write a v2 encrypted store with a random store.key file.
|
|
131
|
+
* The store.key is used directly as the AES-256-GCM key (no PBKDF2).
|
|
132
|
+
*/
|
|
133
|
+
function writeEncryptedStoreV2(entries: Record<string, string>): void {
|
|
134
|
+
const protectedDir = join(testDir, ".vellum", "protected");
|
|
135
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
136
|
+
|
|
137
|
+
const storeKey = randomBytes(KEY_LENGTH);
|
|
138
|
+
writeFileSync(join(protectedDir, "store.key"), storeKey);
|
|
139
|
+
|
|
140
|
+
const store = {
|
|
141
|
+
version: 2,
|
|
142
|
+
entries: encryptEntries(entries, storeKey),
|
|
143
|
+
};
|
|
144
|
+
writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
// ---------------------------------------------------------------------------
|
|
123
148
|
// Broker test helpers — mock UDS server
|
|
124
149
|
// ---------------------------------------------------------------------------
|
|
@@ -264,6 +289,74 @@ describe("readTelegramCredentials", () => {
|
|
|
264
289
|
});
|
|
265
290
|
});
|
|
266
291
|
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Tests: v2 encrypted store (store.key)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
describe("v2 encrypted store with store.key", () => {
|
|
297
|
+
test("reads credential from v2 store when store.key exists", async () => {
|
|
298
|
+
writeEncryptedStoreV2({
|
|
299
|
+
[credentialKey("test", "key")]: "v2-secret-value",
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const result = await readCredential(credentialKey("test", "key"));
|
|
303
|
+
expect(result).toBe("v2-secret-value");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("returns undefined for v2 store when store.key is missing", async () => {
|
|
307
|
+
// Write a v2 store but without the store.key file
|
|
308
|
+
const protectedDir = join(testDir, ".vellum", "protected");
|
|
309
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
310
|
+
|
|
311
|
+
const storeKey = randomBytes(KEY_LENGTH);
|
|
312
|
+
const store = {
|
|
313
|
+
version: 2,
|
|
314
|
+
entries: encryptEntries(
|
|
315
|
+
{ [credentialKey("test", "key")]: "v2-secret-value" },
|
|
316
|
+
storeKey,
|
|
317
|
+
),
|
|
318
|
+
};
|
|
319
|
+
writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
|
|
320
|
+
// Deliberately do NOT write store.key
|
|
321
|
+
|
|
322
|
+
const result = await readCredential(credentialKey("test", "key"));
|
|
323
|
+
expect(result).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("returns Telegram credentials from v2 store", async () => {
|
|
327
|
+
writeMetadata([
|
|
328
|
+
{ service: "telegram", field: "bot_token" },
|
|
329
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
writeEncryptedStoreV2({
|
|
333
|
+
[credentialKey("telegram", "bot_token")]: "v2-bot-token",
|
|
334
|
+
[credentialKey("telegram", "webhook_secret")]: "v2-webhook-secret",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const result = await readTelegramCredentials();
|
|
338
|
+
expect(result).toEqual({
|
|
339
|
+
botToken: "v2-bot-token",
|
|
340
|
+
webhookSecret: "v2-webhook-secret",
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Tests: v1 encrypted store backward compatibility
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
describe("v1 encrypted store backward compatibility", () => {
|
|
350
|
+
test("v1 store continues to work with entropy-based key derivation", async () => {
|
|
351
|
+
writeEncryptedStore({
|
|
352
|
+
[credentialKey("test", "key")]: "v1-secret-value",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const result = await readCredential(credentialKey("test", "key"));
|
|
356
|
+
expect(result).toBe("v1-secret-value");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
267
360
|
// ---------------------------------------------------------------------------
|
|
268
361
|
// Tests: broker credential reading
|
|
269
362
|
// ---------------------------------------------------------------------------
|
|
@@ -68,7 +68,7 @@ function encrypt(
|
|
|
68
68
|
value: string,
|
|
69
69
|
key: Buffer,
|
|
70
70
|
): { iv: string; tag: string; data: string } {
|
|
71
|
-
const iv = cryptoRandomBytes(
|
|
71
|
+
const iv = cryptoRandomBytes(16);
|
|
72
72
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
|
73
73
|
authTagLength: AUTH_TAG_LENGTH,
|
|
74
74
|
});
|
|
@@ -114,6 +114,28 @@ function writeEncryptedStore(botToken: string, webhookSecret: string): void {
|
|
|
114
114
|
writeFileSync(storePath, JSON.stringify(store));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Write Telegram credentials into a v2 encrypted store using a random
|
|
119
|
+
* store.key file (no PBKDF2 derivation).
|
|
120
|
+
*/
|
|
121
|
+
function writeEncryptedStoreV2(botToken: string, webhookSecret: string): void {
|
|
122
|
+
const protectedDir = join(testDir, ".vellum", "protected");
|
|
123
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
const storeKey = cryptoRandomBytes(KEY_LENGTH);
|
|
126
|
+
writeFileSync(join(protectedDir, "store.key"), storeKey);
|
|
127
|
+
|
|
128
|
+
const store = {
|
|
129
|
+
version: 2,
|
|
130
|
+
entries: {
|
|
131
|
+
"credential/telegram/bot_token": encrypt(botToken, storeKey),
|
|
132
|
+
"credential/telegram/webhook_secret": encrypt(webhookSecret, storeKey),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
function metadataRecord(
|
|
118
140
|
credentialId: string,
|
|
119
141
|
service: string,
|
|
@@ -289,4 +311,38 @@ describe("gateway telegram hot-reload (e2e)", () => {
|
|
|
289
311
|
});
|
|
290
312
|
expect(after.status).toBe(401);
|
|
291
313
|
}, 15_000);
|
|
314
|
+
|
|
315
|
+
test("gateway hot-reloads v2 encrypted store credentials written after startup", async () => {
|
|
316
|
+
// --- Setup: no credentials directory exists (fresh hatch) ---
|
|
317
|
+
mkdirSync(testDir, { recursive: true });
|
|
318
|
+
|
|
319
|
+
// Start the real gateway process
|
|
320
|
+
await startGateway();
|
|
321
|
+
|
|
322
|
+
const base = `http://localhost:${port}`;
|
|
323
|
+
|
|
324
|
+
// --- Step 1: confirm Telegram is NOT configured ---
|
|
325
|
+
const before = await fetch(`${base}/webhooks/telegram`, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
});
|
|
328
|
+
expect(before.status).toBe(503);
|
|
329
|
+
const beforeBody = (await before.json()) as { error: string };
|
|
330
|
+
expect(beforeBody.error).toBe("Telegram integration not configured");
|
|
331
|
+
|
|
332
|
+
// --- Step 2: simulate daemon writing v2 credentials ---
|
|
333
|
+
writeEncryptedStoreV2("fake-v2-bot-token:XYZ", "fake-v2-webhook-secret");
|
|
334
|
+
writeCredentialMetadata();
|
|
335
|
+
|
|
336
|
+
// Wait for credential watcher debounce (500ms) + generous margin
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
338
|
+
|
|
339
|
+
// --- Step 3: query again — gateway should now recognize Telegram is configured.
|
|
340
|
+
// We expect 401 (webhook secret verification failed) rather than 503
|
|
341
|
+
// (not configured). Getting past the 503 gate proves the gateway
|
|
342
|
+
// hot-reloaded the v2 credentials from the credential store.
|
|
343
|
+
const after = await fetch(`${base}/webhooks/telegram`, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
});
|
|
346
|
+
expect(after.status).toBe(401);
|
|
347
|
+
}, 15_000);
|
|
292
348
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { SleepWakeDetector } from "../sleep-wake-detector.js";
|
|
3
|
+
|
|
4
|
+
// Suppress logger output during tests
|
|
5
|
+
mock.module("../logger.js", () => ({
|
|
6
|
+
getLogger: () => ({
|
|
7
|
+
info: () => {},
|
|
8
|
+
warn: () => {},
|
|
9
|
+
error: () => {},
|
|
10
|
+
debug: () => {},
|
|
11
|
+
}),
|
|
12
|
+
initLogger: () => {},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("SleepWakeDetector", () => {
|
|
16
|
+
let detector: SleepWakeDetector;
|
|
17
|
+
let onWake: ReturnType<typeof mock>;
|
|
18
|
+
let originalDateNow: () => number;
|
|
19
|
+
let fakeNow: number;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
onWake = mock(() => {});
|
|
23
|
+
originalDateNow = Date.now;
|
|
24
|
+
fakeNow = 1000000;
|
|
25
|
+
Date.now = () => fakeNow;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
detector?.stop();
|
|
30
|
+
Date.now = originalDateNow;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("does not fire callback on normal ticks", async () => {
|
|
34
|
+
detector = new SleepWakeDetector(onWake, 50, 2);
|
|
35
|
+
detector.start();
|
|
36
|
+
|
|
37
|
+
// Advance time normally (within threshold)
|
|
38
|
+
fakeNow += 55;
|
|
39
|
+
await new Promise((r) => setTimeout(r, 70));
|
|
40
|
+
|
|
41
|
+
expect(onWake).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("fires callback when elapsed time exceeds threshold", async () => {
|
|
45
|
+
detector = new SleepWakeDetector(onWake, 50, 2);
|
|
46
|
+
detector.start();
|
|
47
|
+
|
|
48
|
+
// Wait for first tick at normal time
|
|
49
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
50
|
+
|
|
51
|
+
// Simulate a sleep gap: jump time forward well past the threshold
|
|
52
|
+
fakeNow += 200; // 4x the interval
|
|
53
|
+
|
|
54
|
+
// Wait for next tick
|
|
55
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
56
|
+
|
|
57
|
+
expect(onWake).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("stop prevents further callbacks", async () => {
|
|
61
|
+
detector = new SleepWakeDetector(onWake, 50, 2);
|
|
62
|
+
detector.start();
|
|
63
|
+
detector.stop();
|
|
64
|
+
|
|
65
|
+
fakeNow += 500;
|
|
66
|
+
await new Promise((r) => setTimeout(r, 70));
|
|
67
|
+
|
|
68
|
+
expect(onWake).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("start after stop resets cleanly", async () => {
|
|
72
|
+
detector = new SleepWakeDetector(onWake, 50, 2);
|
|
73
|
+
detector.start();
|
|
74
|
+
detector.stop();
|
|
75
|
+
detector.start();
|
|
76
|
+
|
|
77
|
+
// Normal tick — should not fire
|
|
78
|
+
fakeNow += 55;
|
|
79
|
+
await new Promise((r) => setTimeout(r, 70));
|
|
80
|
+
|
|
81
|
+
expect(onWake).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -98,7 +98,9 @@ export function loadConfig(): GatewayConfig {
|
|
|
98
98
|
const gw = (wsConfig.gateway ?? {}) as Record<string, unknown>;
|
|
99
99
|
|
|
100
100
|
const runtimeProxyEnabled =
|
|
101
|
-
gw.runtimeProxyEnabled === true ||
|
|
101
|
+
gw.runtimeProxyEnabled === true ||
|
|
102
|
+
gw.runtimeProxyEnabled === "true" ||
|
|
103
|
+
process.env.RUNTIME_PROXY_ENABLED === "true";
|
|
102
104
|
const runtimeProxyRequireAuth =
|
|
103
105
|
gw.runtimeProxyRequireAuth !== false &&
|
|
104
106
|
gw.runtimeProxyRequireAuth !== "false";
|
|
@@ -138,7 +140,7 @@ export function loadConfig(): GatewayConfig {
|
|
|
138
140
|
telegram: 20 * 1024 * 1024, // Telegram Bot API getFile limit
|
|
139
141
|
slack: 100 * 1024 * 1024, // Slack standard plan
|
|
140
142
|
whatsapp: 16 * 1024 * 1024, // WhatsApp Business API limit
|
|
141
|
-
default:
|
|
143
|
+
default: 100 * 1024 * 1024, // Fallback; capped by runtime MAX_UPLOAD_BYTES (100 MB)
|
|
142
144
|
},
|
|
143
145
|
maxAttachmentConcurrency: 3,
|
|
144
146
|
maxWebhookPayloadBytes: 1024 * 1024,
|
package/src/credential-reader.ts
CHANGED
|
@@ -26,12 +26,21 @@ interface EncryptedEntry {
|
|
|
26
26
|
data: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
interface
|
|
29
|
+
interface StoreFileV1 {
|
|
30
30
|
version: 1;
|
|
31
31
|
salt: string;
|
|
32
32
|
entries: Record<string, EncryptedEntry>;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
interface StoreFileV2 {
|
|
36
|
+
version: 2;
|
|
37
|
+
entries: Record<string, EncryptedEntry>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type StoreFile = StoreFileV1 | StoreFileV2;
|
|
41
|
+
|
|
42
|
+
const STORE_KEY_FILENAME = "store.key";
|
|
43
|
+
|
|
35
44
|
function getPlatformName(): string {
|
|
36
45
|
// Must match assistant/src/util/platform.ts#getPlatformName exactly.
|
|
37
46
|
// Using user-friendly labels like "macOS" here changes PBKDF2 entropy and
|
|
@@ -66,6 +75,22 @@ function deriveKey(salt: Buffer): Buffer {
|
|
|
66
75
|
return pbkdf2Sync(entropy, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha512");
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Read the v2 store key file (~/.vellum/protected/store.key).
|
|
80
|
+
* Returns null if the file doesn't exist or isn't exactly 32 bytes.
|
|
81
|
+
*/
|
|
82
|
+
function readStoreKey(): Buffer | null {
|
|
83
|
+
const keyPath = join(getRootDir(), "protected", STORE_KEY_FILENAME);
|
|
84
|
+
if (!existsSync(keyPath)) return null;
|
|
85
|
+
try {
|
|
86
|
+
const buf = readFileSync(keyPath);
|
|
87
|
+
if (buf.length !== KEY_LENGTH) return null;
|
|
88
|
+
return buf;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
function decrypt(entry: EncryptedEntry, key: Buffer): string {
|
|
70
95
|
const iv = Buffer.from(entry.iv, "hex");
|
|
71
96
|
const tag = Buffer.from(entry.tag, "hex");
|
|
@@ -83,17 +108,26 @@ function readStore(storePath: string): StoreFile | null {
|
|
|
83
108
|
|
|
84
109
|
const raw = readFileSync(storePath, "utf-8");
|
|
85
110
|
const parsed = JSON.parse(raw);
|
|
111
|
+
|
|
112
|
+
if (parsed.version === 2 && typeof parsed.entries === "object") {
|
|
113
|
+
const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
|
|
114
|
+
Object.assign(safeEntries, parsed.entries);
|
|
115
|
+
parsed.entries = safeEntries;
|
|
116
|
+
return parsed as StoreFileV2;
|
|
117
|
+
}
|
|
118
|
+
|
|
86
119
|
if (
|
|
87
|
-
parsed.version
|
|
88
|
-
typeof parsed.salt
|
|
89
|
-
typeof parsed.entries
|
|
120
|
+
parsed.version === 1 &&
|
|
121
|
+
typeof parsed.salt === "string" &&
|
|
122
|
+
typeof parsed.entries === "object"
|
|
90
123
|
) {
|
|
91
|
-
|
|
124
|
+
const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
|
|
125
|
+
Object.assign(safeEntries, parsed.entries);
|
|
126
|
+
parsed.entries = safeEntries;
|
|
127
|
+
return parsed as StoreFileV1;
|
|
92
128
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
parsed.entries = safeEntries;
|
|
96
|
-
return parsed as StoreFile;
|
|
129
|
+
|
|
130
|
+
throw new Error("Encrypted store has invalid format");
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
export function getRootDir(): string {
|
|
@@ -125,6 +159,9 @@ export function getMetadataPath(): string {
|
|
|
125
159
|
* Read a single credential from the encrypted store.
|
|
126
160
|
* Returns `undefined` if the store doesn't exist, the key is missing,
|
|
127
161
|
* or decryption fails.
|
|
162
|
+
*
|
|
163
|
+
* For v2 stores, uses the store.key file directly as the AES key.
|
|
164
|
+
* For v1 stores, derives the key from machine entropy via PBKDF2.
|
|
128
165
|
*/
|
|
129
166
|
function readEncryptedCredential(account: string): string | undefined {
|
|
130
167
|
try {
|
|
@@ -134,8 +171,15 @@ function readEncryptedCredential(account: string): string | undefined {
|
|
|
134
171
|
const entry = store.entries[account];
|
|
135
172
|
if (!entry) return undefined;
|
|
136
173
|
|
|
137
|
-
|
|
138
|
-
|
|
174
|
+
let key: Buffer;
|
|
175
|
+
if (store.version === 2) {
|
|
176
|
+
const storeKey = readStoreKey();
|
|
177
|
+
if (!storeKey) return undefined;
|
|
178
|
+
key = storeKey;
|
|
179
|
+
} else {
|
|
180
|
+
const salt = Buffer.from(store.salt, "hex");
|
|
181
|
+
key = deriveKey(salt);
|
|
182
|
+
}
|
|
139
183
|
return decrypt(entry, key);
|
|
140
184
|
} catch (err) {
|
|
141
185
|
log.debug({ err, account }, "Failed to read from encrypted store");
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Watches the assistant's credential metadata file
|
|
3
|
-
*
|
|
2
|
+
* Watches the assistant's credential metadata file and the v2 store key
|
|
3
|
+
* for changes, triggering a callback when channel credentials are added,
|
|
4
4
|
* updated, or removed.
|
|
5
5
|
*
|
|
6
|
-
* Watches
|
|
6
|
+
* Watches parent directories rather than files themselves because
|
|
7
7
|
* metadata.json is rewritten via atomic rename. File-scoped fs.watch()
|
|
8
8
|
* subscriptions can stay attached to the old inode after the first write,
|
|
9
9
|
* causing later credential changes to be missed until restart.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { mkdirSync, watch, type FSWatcher } from "node:fs";
|
|
13
|
-
import { dirname } from "node:path";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
14
|
import { getLogger } from "./logger.js";
|
|
15
15
|
import {
|
|
16
16
|
getMetadataPath,
|
|
17
|
+
getRootDir,
|
|
17
18
|
readTelegramCredentials,
|
|
18
19
|
readTwilioCredentials,
|
|
19
20
|
readWhatsAppCredentials,
|
|
@@ -42,7 +43,7 @@ export type CredentialChangeEvent = {
|
|
|
42
43
|
export type CredentialChangeCallback = (event: CredentialChangeEvent) => void;
|
|
43
44
|
|
|
44
45
|
export class CredentialWatcher {
|
|
45
|
-
private
|
|
46
|
+
private watchers: FSWatcher[] = [];
|
|
46
47
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
47
48
|
private lastSerialized: Map<string, string> = new Map();
|
|
48
49
|
private polling = false;
|
|
@@ -58,30 +59,38 @@ export class CredentialWatcher {
|
|
|
58
59
|
async start(): Promise<void> {
|
|
59
60
|
await this.pollOnce();
|
|
60
61
|
|
|
61
|
-
const
|
|
62
|
+
const metadataDir = dirname(this.metadataPath);
|
|
63
|
+
const protectedDir = join(getRootDir(), "protected");
|
|
62
64
|
|
|
63
|
-
// Ensure
|
|
65
|
+
// Ensure directories exist so fs.watch() doesn't throw ENOENT
|
|
64
66
|
// on a fresh hatch where no credentials have been written yet.
|
|
65
|
-
mkdirSync(
|
|
67
|
+
mkdirSync(metadataDir, { recursive: true });
|
|
68
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
66
69
|
|
|
70
|
+
// Watch the metadata directory for metadata.json changes.
|
|
71
|
+
this.startWatcher(metadataDir, "metadata.json");
|
|
72
|
+
|
|
73
|
+
// Watch the protected directory for store.key changes so that
|
|
74
|
+
// creating or restoring the v2 store key triggers a credential reload.
|
|
75
|
+
this.startWatcher(protectedDir, "store.key");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private startWatcher(dir: string, targetFilename: string): void {
|
|
67
79
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.scheduleCheck();
|
|
76
|
-
},
|
|
77
|
-
);
|
|
80
|
+
const watcher = watch(dir, { persistent: false }, (_event, filename) => {
|
|
81
|
+
if (filename && filename !== targetFilename) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.scheduleCheck();
|
|
85
|
+
});
|
|
86
|
+
this.watchers.push(watcher);
|
|
78
87
|
|
|
79
|
-
log.info(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
{ err, path: watchTarget },
|
|
83
|
-
"Failed to start credential file watcher",
|
|
88
|
+
log.info(
|
|
89
|
+
{ path: dir, file: targetFilename },
|
|
90
|
+
"Watching for credential changes",
|
|
84
91
|
);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.warn({ err, path: dir }, "Failed to start credential file watcher");
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
|
|
@@ -91,10 +100,10 @@ export class CredentialWatcher {
|
|
|
91
100
|
this.debounceTimer = null;
|
|
92
101
|
}
|
|
93
102
|
this.pendingPoll = false;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
this.watcher = null;
|
|
103
|
+
for (const watcher of this.watchers) {
|
|
104
|
+
watcher.close();
|
|
97
105
|
}
|
|
106
|
+
this.watchers = [];
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
private scheduleCheck(): void {
|
|
@@ -41,14 +41,6 @@
|
|
|
41
41
|
"description": "Enable multi-file TSX app creation with esbuild compilation instead of single-HTML apps",
|
|
42
42
|
"defaultEnabled": false
|
|
43
43
|
},
|
|
44
|
-
{
|
|
45
|
-
"id": "sentry-testing",
|
|
46
|
-
"scope": "macos",
|
|
47
|
-
"key": "sentry_testing_enabled",
|
|
48
|
-
"label": "Sentry Testing",
|
|
49
|
-
"description": "Show the Sentry Testing tab in Settings for triggering test crash reports and error events",
|
|
50
|
-
"defaultEnabled": false
|
|
51
|
-
},
|
|
52
44
|
{
|
|
53
45
|
"id": "mobile-pairing",
|
|
54
46
|
"scope": "macos",
|
|
@@ -226,19 +218,43 @@
|
|
|
226
218
|
"defaultEnabled": false
|
|
227
219
|
},
|
|
228
220
|
{
|
|
229
|
-
"id": "
|
|
230
|
-
"scope": "
|
|
231
|
-
"key": "
|
|
232
|
-
"label": "
|
|
233
|
-
"description": "
|
|
221
|
+
"id": "integration-notion",
|
|
222
|
+
"scope": "assistant",
|
|
223
|
+
"key": "feature_flags.integration-notion.enabled",
|
|
224
|
+
"label": "Notion Integration",
|
|
225
|
+
"description": "Enable the Notion setup skill for connecting to Notion",
|
|
226
|
+
"defaultEnabled": false
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
"id": "conversation-starters",
|
|
230
|
+
"scope": "assistant",
|
|
231
|
+
"key": "feature_flags.conversation-starters.enabled",
|
|
232
|
+
"label": "Recommended Starts",
|
|
233
|
+
"description": "Show a curated row of recommended starting actions on the empty conversation page, ordered by relevance as confident first-click options",
|
|
234
|
+
"defaultEnabled": true
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"id": "deploy-to-vercel",
|
|
238
|
+
"scope": "assistant",
|
|
239
|
+
"key": "feature_flags.deploy-to-vercel.enabled",
|
|
240
|
+
"label": "Deploy to Vercel",
|
|
241
|
+
"description": "Enable the Deploy to Vercel / Publish option in the app workspace header share menu",
|
|
242
|
+
"defaultEnabled": false
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"id": "managed-google-oauth",
|
|
246
|
+
"scope": "assistant",
|
|
247
|
+
"key": "feature_flags.managed-google-oauth.enabled",
|
|
248
|
+
"label": "Managed Google OAuth",
|
|
249
|
+
"description": "Show the Google OAuth service card in Models & Services settings",
|
|
234
250
|
"defaultEnabled": false
|
|
235
251
|
},
|
|
236
252
|
{
|
|
237
|
-
"id": "
|
|
253
|
+
"id": "quick-input",
|
|
238
254
|
"scope": "macos",
|
|
239
|
-
"key": "
|
|
240
|
-
"label": "
|
|
241
|
-
"description": "
|
|
255
|
+
"key": "quick_input_enabled",
|
|
256
|
+
"label": "Quick Input",
|
|
257
|
+
"description": "Enable the Quick Input popover on right-click of the menu bar icon",
|
|
242
258
|
"defaultEnabled": false
|
|
243
259
|
}
|
|
244
260
|
]
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
type RouteDefinition,
|
|
68
68
|
type GetClientIp,
|
|
69
69
|
} from "./http/router.js";
|
|
70
|
+
import { SleepWakeDetector } from "./sleep-wake-detector.js";
|
|
70
71
|
import { callTelegramApi } from "./telegram/api.js";
|
|
71
72
|
import { reconcileTelegramWebhook } from "./telegram/webhook-manager.js";
|
|
72
73
|
|
|
@@ -663,6 +664,13 @@ async function main() {
|
|
|
663
664
|
channelReadinessProxy.handleRefreshChannelReadiness(req),
|
|
664
665
|
},
|
|
665
666
|
|
|
667
|
+
{
|
|
668
|
+
path: /^\/v1\/assistants\/([^/]+)\/channels\/readiness\/$/,
|
|
669
|
+
method: "GET",
|
|
670
|
+
auth: "edge",
|
|
671
|
+
handler: (req) => channelReadinessProxy.handleGetChannelReadiness(req),
|
|
672
|
+
},
|
|
673
|
+
|
|
666
674
|
// ── Integration status ──
|
|
667
675
|
{
|
|
668
676
|
path: "/integrations/status",
|
|
@@ -675,6 +683,17 @@ async function main() {
|
|
|
675
683
|
},
|
|
676
684
|
}),
|
|
677
685
|
},
|
|
686
|
+
{
|
|
687
|
+
path: /^\/v1\/assistants\/([^/]+)\/integrations\/status\/$/,
|
|
688
|
+
method: "GET",
|
|
689
|
+
auth: "edge",
|
|
690
|
+
handler: () =>
|
|
691
|
+
Response.json({
|
|
692
|
+
email: {
|
|
693
|
+
address: configFileCache.getString("email", "address") ?? null,
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
},
|
|
678
697
|
|
|
679
698
|
// ── Feature flags (scope-protected) ──
|
|
680
699
|
{
|
|
@@ -684,6 +703,13 @@ async function main() {
|
|
|
684
703
|
scope: "feature_flags.read",
|
|
685
704
|
handler: (req) => handleFeatureFlagsGet(req),
|
|
686
705
|
},
|
|
706
|
+
{
|
|
707
|
+
path: /^\/v1\/assistants\/([^/]+)\/feature-flags\/$/,
|
|
708
|
+
method: "GET",
|
|
709
|
+
auth: "edge-scoped",
|
|
710
|
+
scope: "feature_flags.read",
|
|
711
|
+
handler: (req) => handleFeatureFlagsGet(req),
|
|
712
|
+
},
|
|
687
713
|
{
|
|
688
714
|
path: /^\/v1\/feature-flags\/(.+)$/,
|
|
689
715
|
method: "PATCH",
|
|
@@ -702,6 +728,24 @@ async function main() {
|
|
|
702
728
|
return handleFeatureFlagsPatch(req, flagKey);
|
|
703
729
|
},
|
|
704
730
|
},
|
|
731
|
+
{
|
|
732
|
+
path: /^\/v1\/assistants\/([^/]+)\/feature-flags\/(.+)$/,
|
|
733
|
+
method: "PATCH",
|
|
734
|
+
auth: "edge-scoped",
|
|
735
|
+
scope: "feature_flags.write",
|
|
736
|
+
handler: (req, params) => {
|
|
737
|
+
let flagKey: string;
|
|
738
|
+
try {
|
|
739
|
+
flagKey = decodeURIComponent(params[1].replace(/\/$/, ""));
|
|
740
|
+
} catch {
|
|
741
|
+
return Response.json(
|
|
742
|
+
{ error: "Invalid flag key encoding" },
|
|
743
|
+
{ status: 400 },
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
return handleFeatureFlagsPatch(req, flagKey);
|
|
747
|
+
},
|
|
748
|
+
},
|
|
705
749
|
|
|
706
750
|
// ── Privacy config (scope-protected) ──
|
|
707
751
|
{
|
|
@@ -711,6 +755,13 @@ async function main() {
|
|
|
711
755
|
scope: "settings.write",
|
|
712
756
|
handler: (req) => handlePrivacyConfigPatch(req),
|
|
713
757
|
},
|
|
758
|
+
{
|
|
759
|
+
path: /^\/v1\/assistants\/([^/]+)\/config\/privacy\/$/,
|
|
760
|
+
method: "PATCH",
|
|
761
|
+
auth: "edge-scoped",
|
|
762
|
+
scope: "settings.write",
|
|
763
|
+
handler: (req) => handlePrivacyConfigPatch(req),
|
|
764
|
+
},
|
|
714
765
|
];
|
|
715
766
|
|
|
716
767
|
// The runtime proxy catch-all is only added when the proxy is enabled.
|
|
@@ -785,6 +836,25 @@ async function main() {
|
|
|
785
836
|
if (draining) {
|
|
786
837
|
return Response.json({ status: "draining" }, { status: 503 });
|
|
787
838
|
}
|
|
839
|
+
// Check that the upstream assistant is also reachable so callers
|
|
840
|
+
// know the full stack is ready, not just the gateway process.
|
|
841
|
+
try {
|
|
842
|
+
const upstream = await fetch(
|
|
843
|
+
`${config.assistantRuntimeBaseUrl}/healthz`,
|
|
844
|
+
{ signal: AbortSignal.timeout(3000) },
|
|
845
|
+
);
|
|
846
|
+
if (!upstream.ok) {
|
|
847
|
+
return Response.json(
|
|
848
|
+
{ status: "upstream_unhealthy", upstream: upstream.status },
|
|
849
|
+
{ status: 503 },
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
} catch {
|
|
853
|
+
return Response.json(
|
|
854
|
+
{ status: "upstream_unreachable" },
|
|
855
|
+
{ status: 503 },
|
|
856
|
+
);
|
|
857
|
+
}
|
|
788
858
|
return Response.json({ status: "ok" });
|
|
789
859
|
}
|
|
790
860
|
|
|
@@ -983,11 +1053,34 @@ async function main() {
|
|
|
983
1053
|
|
|
984
1054
|
configFileWatcher.start();
|
|
985
1055
|
|
|
1056
|
+
// ── Sleep/wake detection ──
|
|
1057
|
+
// Detect system sleep/wake transitions and force-reconnect channels
|
|
1058
|
+
// that may have stale connections after the OS suspended the process.
|
|
1059
|
+
const sleepWakeDetector = new SleepWakeDetector(() => {
|
|
1060
|
+
log.info("System wake detected — reconnecting channels");
|
|
1061
|
+
|
|
1062
|
+
// Force-reconnect Slack WebSocket (may be half-open after sleep)
|
|
1063
|
+
slackSocketClient?.forceReconnect();
|
|
1064
|
+
|
|
1065
|
+
// Invalidate caches so next read picks up any config changes (e.g. new ngrok URL)
|
|
1066
|
+
configFileCache.invalidate();
|
|
1067
|
+
credentialCache.invalidate();
|
|
1068
|
+
|
|
1069
|
+
// Re-register Telegram webhook with current ingress URL
|
|
1070
|
+
if (telegramReady) {
|
|
1071
|
+
reconcileTelegramWebhook(telegramCaches).catch((err) => {
|
|
1072
|
+
log.error({ err }, "Failed to reconcile Telegram webhook after wake");
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
sleepWakeDetector.start();
|
|
1077
|
+
|
|
986
1078
|
const drainMs = config.shutdownDrainMs;
|
|
987
1079
|
|
|
988
1080
|
process.on("SIGTERM", () => {
|
|
989
1081
|
log.info("SIGTERM received, starting graceful shutdown");
|
|
990
1082
|
draining = true;
|
|
1083
|
+
sleepWakeDetector.stop();
|
|
991
1084
|
credentialWatcher.stop();
|
|
992
1085
|
configFileWatcher.stop();
|
|
993
1086
|
telegramDedupCache.stopCleanup();
|
package/src/schema.ts
CHANGED
|
@@ -1633,6 +1633,32 @@ export function buildSchema(): Record<string, unknown> {
|
|
|
1633
1633
|
},
|
|
1634
1634
|
},
|
|
1635
1635
|
},
|
|
1636
|
+
"/v1/assistants/{assistantId}/channels/readiness/": {
|
|
1637
|
+
get: {
|
|
1638
|
+
summary: "Get channel readiness (scoped)",
|
|
1639
|
+
description:
|
|
1640
|
+
"Authenticated gateway endpoint that returns the readiness status of all configured channels from the assistant runtime. The assistantId path segment is used for routing but does not affect the response.",
|
|
1641
|
+
operationId: "channelReadinessScopedGet",
|
|
1642
|
+
parameters: [
|
|
1643
|
+
{
|
|
1644
|
+
name: "assistantId",
|
|
1645
|
+
in: "path",
|
|
1646
|
+
required: true,
|
|
1647
|
+
schema: { type: "string" },
|
|
1648
|
+
},
|
|
1649
|
+
],
|
|
1650
|
+
security: [{ BearerAuth: [] }],
|
|
1651
|
+
responses: {
|
|
1652
|
+
"200": { description: "Channel readiness status returned" },
|
|
1653
|
+
"401": {
|
|
1654
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
1655
|
+
},
|
|
1656
|
+
"503": { description: "Bearer token not configured" },
|
|
1657
|
+
"502": { description: "Failed to reach assistant runtime" },
|
|
1658
|
+
"504": { description: "Assistant runtime request timed out" },
|
|
1659
|
+
},
|
|
1660
|
+
},
|
|
1661
|
+
},
|
|
1636
1662
|
"/v1/channels/readiness/refresh": {
|
|
1637
1663
|
post: {
|
|
1638
1664
|
summary: "Refresh channel readiness",
|
|
@@ -1758,6 +1784,130 @@ export function buildSchema(): Record<string, unknown> {
|
|
|
1758
1784
|
},
|
|
1759
1785
|
},
|
|
1760
1786
|
},
|
|
1787
|
+
"/v1/assistants/{assistantId}/feature-flags/": {
|
|
1788
|
+
get: {
|
|
1789
|
+
summary: "List feature flags (assistant-scoped)",
|
|
1790
|
+
description:
|
|
1791
|
+
"Assistant-scoped variant of the feature flags endpoint. Requires a bearer token with `feature_flags.read` scope.",
|
|
1792
|
+
operationId: "assistantFeatureFlagsGet",
|
|
1793
|
+
security: [{ BearerAuth: [] }],
|
|
1794
|
+
parameters: [
|
|
1795
|
+
{
|
|
1796
|
+
name: "assistantId",
|
|
1797
|
+
in: "path",
|
|
1798
|
+
required: true,
|
|
1799
|
+
schema: { type: "string" },
|
|
1800
|
+
description: "The assistant identifier.",
|
|
1801
|
+
},
|
|
1802
|
+
],
|
|
1803
|
+
responses: {
|
|
1804
|
+
"200": { description: "Feature flags returned" },
|
|
1805
|
+
"401": {
|
|
1806
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
1807
|
+
},
|
|
1808
|
+
"403": { description: "Insufficient scope" },
|
|
1809
|
+
},
|
|
1810
|
+
},
|
|
1811
|
+
},
|
|
1812
|
+
"/v1/assistants/{assistantId}/feature-flags/{flagKey}": {
|
|
1813
|
+
patch: {
|
|
1814
|
+
summary: "Update a feature flag (assistant-scoped)",
|
|
1815
|
+
description:
|
|
1816
|
+
"Assistant-scoped variant of the feature flag update endpoint. Requires a bearer token with `feature_flags.write` scope.",
|
|
1817
|
+
operationId: "assistantFeatureFlagsPatch",
|
|
1818
|
+
security: [{ BearerAuth: [] }],
|
|
1819
|
+
parameters: [
|
|
1820
|
+
{
|
|
1821
|
+
name: "assistantId",
|
|
1822
|
+
in: "path",
|
|
1823
|
+
required: true,
|
|
1824
|
+
schema: { type: "string" },
|
|
1825
|
+
description: "The assistant identifier.",
|
|
1826
|
+
},
|
|
1827
|
+
{
|
|
1828
|
+
name: "flagKey",
|
|
1829
|
+
in: "path",
|
|
1830
|
+
required: true,
|
|
1831
|
+
schema: { type: "string" },
|
|
1832
|
+
description: "The feature flag key to update.",
|
|
1833
|
+
},
|
|
1834
|
+
],
|
|
1835
|
+
requestBody: {
|
|
1836
|
+
required: true,
|
|
1837
|
+
content: {
|
|
1838
|
+
"application/json": {
|
|
1839
|
+
schema: { type: "object", additionalProperties: true },
|
|
1840
|
+
},
|
|
1841
|
+
},
|
|
1842
|
+
},
|
|
1843
|
+
responses: {
|
|
1844
|
+
"200": { description: "Feature flag updated" },
|
|
1845
|
+
"400": { description: "Invalid flag key encoding" },
|
|
1846
|
+
"401": {
|
|
1847
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
1848
|
+
},
|
|
1849
|
+
"403": { description: "Insufficient scope" },
|
|
1850
|
+
},
|
|
1851
|
+
},
|
|
1852
|
+
},
|
|
1853
|
+
"/v1/assistants/{assistantId}/config/privacy/": {
|
|
1854
|
+
patch: {
|
|
1855
|
+
summary: "Update privacy config (assistant-scoped)",
|
|
1856
|
+
description:
|
|
1857
|
+
"Assistant-scoped variant of the privacy config endpoint. Requires a bearer token with `settings.write` scope.",
|
|
1858
|
+
operationId: "assistantPrivacyConfigPatch",
|
|
1859
|
+
security: [{ BearerAuth: [] }],
|
|
1860
|
+
parameters: [
|
|
1861
|
+
{
|
|
1862
|
+
name: "assistantId",
|
|
1863
|
+
in: "path",
|
|
1864
|
+
required: true,
|
|
1865
|
+
schema: { type: "string" },
|
|
1866
|
+
description: "The assistant identifier.",
|
|
1867
|
+
},
|
|
1868
|
+
],
|
|
1869
|
+
requestBody: {
|
|
1870
|
+
required: true,
|
|
1871
|
+
content: {
|
|
1872
|
+
"application/json": {
|
|
1873
|
+
schema: {
|
|
1874
|
+
type: "object",
|
|
1875
|
+
properties: {
|
|
1876
|
+
collectUsageData: { type: "boolean" },
|
|
1877
|
+
sendDiagnostics: { type: "boolean" },
|
|
1878
|
+
},
|
|
1879
|
+
anyOf: [
|
|
1880
|
+
{ required: ["collectUsageData"] },
|
|
1881
|
+
{ required: ["sendDiagnostics"] },
|
|
1882
|
+
],
|
|
1883
|
+
},
|
|
1884
|
+
},
|
|
1885
|
+
},
|
|
1886
|
+
},
|
|
1887
|
+
responses: {
|
|
1888
|
+
"200": {
|
|
1889
|
+
description: "Privacy config updated",
|
|
1890
|
+
content: {
|
|
1891
|
+
"application/json": {
|
|
1892
|
+
schema: {
|
|
1893
|
+
type: "object",
|
|
1894
|
+
properties: {
|
|
1895
|
+
collectUsageData: { type: "boolean" },
|
|
1896
|
+
sendDiagnostics: { type: "boolean" },
|
|
1897
|
+
},
|
|
1898
|
+
},
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
},
|
|
1902
|
+
"400": { description: "Invalid request body" },
|
|
1903
|
+
"401": {
|
|
1904
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
1905
|
+
},
|
|
1906
|
+
"403": { description: "Insufficient scope" },
|
|
1907
|
+
"500": { description: "Internal server error" },
|
|
1908
|
+
},
|
|
1909
|
+
},
|
|
1910
|
+
},
|
|
1761
1911
|
"/integrations/status": {
|
|
1762
1912
|
get: {
|
|
1763
1913
|
summary: "Integration status",
|
|
@@ -1795,6 +1945,51 @@ export function buildSchema(): Record<string, unknown> {
|
|
|
1795
1945
|
},
|
|
1796
1946
|
},
|
|
1797
1947
|
},
|
|
1948
|
+
"/v1/assistants/{assistantId}/integrations/status/": {
|
|
1949
|
+
get: {
|
|
1950
|
+
summary: "Integration status (scoped)",
|
|
1951
|
+
description:
|
|
1952
|
+
"Returns the current status of configured integrations, including the assistant's email address. Requires a valid bearer token. The assistantId path segment is used for routing but does not affect the response.",
|
|
1953
|
+
operationId: "integrationsStatusScoped",
|
|
1954
|
+
parameters: [
|
|
1955
|
+
{
|
|
1956
|
+
name: "assistantId",
|
|
1957
|
+
in: "path",
|
|
1958
|
+
required: true,
|
|
1959
|
+
schema: { type: "string" },
|
|
1960
|
+
},
|
|
1961
|
+
],
|
|
1962
|
+
security: [{ BearerAuth: [] }],
|
|
1963
|
+
responses: {
|
|
1964
|
+
"200": {
|
|
1965
|
+
description: "Integration status",
|
|
1966
|
+
content: {
|
|
1967
|
+
"application/json": {
|
|
1968
|
+
schema: {
|
|
1969
|
+
$ref: "#/components/schemas/IntegrationsStatusResponse",
|
|
1970
|
+
},
|
|
1971
|
+
},
|
|
1972
|
+
},
|
|
1973
|
+
},
|
|
1974
|
+
"401": {
|
|
1975
|
+
description: "Unauthorized — missing or invalid bearer token",
|
|
1976
|
+
content: {
|
|
1977
|
+
"application/json": {
|
|
1978
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
1979
|
+
},
|
|
1980
|
+
},
|
|
1981
|
+
},
|
|
1982
|
+
"503": {
|
|
1983
|
+
description: "Bearer token not configured",
|
|
1984
|
+
content: {
|
|
1985
|
+
"application/json": {
|
|
1986
|
+
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
|
1987
|
+
},
|
|
1988
|
+
},
|
|
1989
|
+
},
|
|
1990
|
+
},
|
|
1991
|
+
},
|
|
1992
|
+
},
|
|
1798
1993
|
"/deliver/telegram": {
|
|
1799
1994
|
post: {
|
|
1800
1995
|
summary: "Telegram delivery (internal)",
|
package/src/slack/socket-mode.ts
CHANGED
|
@@ -51,6 +51,7 @@ export class SlackSocketModeClient {
|
|
|
51
51
|
private config: SlackSocketModeConfig;
|
|
52
52
|
private onEvent: (event: NormalizedSlackEvent) => void;
|
|
53
53
|
private ws: WebSocket | null = null;
|
|
54
|
+
private connecting = false;
|
|
54
55
|
private running = false;
|
|
55
56
|
private reconnectAttempt = 0;
|
|
56
57
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -133,6 +134,7 @@ export class SlackSocketModeClient {
|
|
|
133
134
|
|
|
134
135
|
stop(): void {
|
|
135
136
|
this.running = false;
|
|
137
|
+
this.connecting = false;
|
|
136
138
|
this.stopDedupCleanup();
|
|
137
139
|
if (this.reconnectTimer) {
|
|
138
140
|
clearTimeout(this.reconnectTimer);
|
|
@@ -148,6 +150,89 @@ export class SlackSocketModeClient {
|
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Force-close the current WebSocket and reconnect immediately.
|
|
155
|
+
* Used by the sleep/wake detector to recover from half-open connections
|
|
156
|
+
* that survive system sleep.
|
|
157
|
+
*
|
|
158
|
+
* Waits for the old socket to fully close before connecting a new one
|
|
159
|
+
* to prevent overlapping connections where stale message events could
|
|
160
|
+
* be ACKed on the wrong socket.
|
|
161
|
+
*/
|
|
162
|
+
forceReconnect(): void {
|
|
163
|
+
if (!this.running) return;
|
|
164
|
+
|
|
165
|
+
log.info("Force-reconnecting Slack Socket Mode (sleep/wake recovery)");
|
|
166
|
+
|
|
167
|
+
if (this.reconnectTimer) {
|
|
168
|
+
clearTimeout(this.reconnectTimer);
|
|
169
|
+
this.reconnectTimer = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.reconnectAttempt = 0;
|
|
173
|
+
|
|
174
|
+
const oldWs = this.ws;
|
|
175
|
+
this.ws = null;
|
|
176
|
+
|
|
177
|
+
// If a connect() call is already in-flight (awaiting getWebSocketUrl),
|
|
178
|
+
// don't start another one — the in-flight attempt will complete and
|
|
179
|
+
// establish a fresh connection. We still tear down the old socket and
|
|
180
|
+
// cancel the reconnect timer above so there's no stale state.
|
|
181
|
+
if (this.connecting) {
|
|
182
|
+
log.info(
|
|
183
|
+
"Connect already in-flight, skipping duplicate — tearing down old socket only",
|
|
184
|
+
);
|
|
185
|
+
if (oldWs) {
|
|
186
|
+
try {
|
|
187
|
+
oldWs.close(1000, "force reconnect");
|
|
188
|
+
} catch {
|
|
189
|
+
// ignore
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!oldWs || oldWs.readyState === WebSocket.CLOSED) {
|
|
196
|
+
this.connect().catch((err) => {
|
|
197
|
+
log.error({ err }, "Force reconnect failed");
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Wait for the old socket to fully close before opening a new one.
|
|
203
|
+
// Use a timeout to avoid blocking indefinitely on half-open sockets
|
|
204
|
+
// that may never emit a close event (the exact scenario that triggers
|
|
205
|
+
// a force reconnect after sleep).
|
|
206
|
+
const CLOSE_TIMEOUT_MS = 5_000;
|
|
207
|
+
let settled = false;
|
|
208
|
+
|
|
209
|
+
const proceed = () => {
|
|
210
|
+
if (settled) return;
|
|
211
|
+
settled = true;
|
|
212
|
+
this.connect().catch((err) => {
|
|
213
|
+
log.error({ err }, "Force reconnect failed");
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
oldWs.addEventListener("close", proceed, { once: true });
|
|
218
|
+
|
|
219
|
+
setTimeout(() => {
|
|
220
|
+
if (!settled) {
|
|
221
|
+
log.warn(
|
|
222
|
+
"Old Slack socket did not close within timeout, proceeding with reconnect",
|
|
223
|
+
);
|
|
224
|
+
proceed();
|
|
225
|
+
}
|
|
226
|
+
}, CLOSE_TIMEOUT_MS);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
oldWs.close(1000, "force reconnect");
|
|
230
|
+
} catch {
|
|
231
|
+
// Socket may already be in a broken state — proceed immediately
|
|
232
|
+
proceed();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
151
236
|
/**
|
|
152
237
|
* Register a thread as active so future replies (without @mention) are forwarded.
|
|
153
238
|
*/
|
|
@@ -157,12 +242,15 @@ export class SlackSocketModeClient {
|
|
|
157
242
|
|
|
158
243
|
private async connect(): Promise<void> {
|
|
159
244
|
if (!this.running) return;
|
|
245
|
+
if (this.connecting) return;
|
|
246
|
+
this.connecting = true;
|
|
160
247
|
|
|
161
248
|
let wsUrl: string;
|
|
162
249
|
try {
|
|
163
250
|
wsUrl = await this.getWebSocketUrl();
|
|
164
251
|
} catch (err) {
|
|
165
252
|
log.error({ err }, "Failed to obtain Socket Mode WebSocket URL");
|
|
253
|
+
this.connecting = false;
|
|
166
254
|
this.scheduleReconnect();
|
|
167
255
|
return;
|
|
168
256
|
}
|
|
@@ -172,6 +260,7 @@ export class SlackSocketModeClient {
|
|
|
172
260
|
try {
|
|
173
261
|
const ws = new WebSocket(wsUrl);
|
|
174
262
|
this.ws = ws;
|
|
263
|
+
this.connecting = false;
|
|
175
264
|
|
|
176
265
|
ws.addEventListener("open", () => {
|
|
177
266
|
log.info("Slack Socket Mode connected");
|
|
@@ -179,7 +268,7 @@ export class SlackSocketModeClient {
|
|
|
179
268
|
});
|
|
180
269
|
|
|
181
270
|
ws.addEventListener("message", (messageEvent) => {
|
|
182
|
-
this.handleMessage(messageEvent.data as string);
|
|
271
|
+
this.handleMessage(messageEvent.data as string, ws);
|
|
183
272
|
});
|
|
184
273
|
|
|
185
274
|
ws.addEventListener("close", (closeEvent) => {
|
|
@@ -187,8 +276,13 @@ export class SlackSocketModeClient {
|
|
|
187
276
|
{ code: closeEvent.code, reason: closeEvent.reason },
|
|
188
277
|
"Slack Socket Mode disconnected",
|
|
189
278
|
);
|
|
190
|
-
this
|
|
191
|
-
this.
|
|
279
|
+
// Only reconnect if this socket is still the active one.
|
|
280
|
+
// forceReconnect nulls this.ws before initiating a new connection,
|
|
281
|
+
// so a stale close event should be ignored.
|
|
282
|
+
if (this.ws === ws) {
|
|
283
|
+
this.ws = null;
|
|
284
|
+
this.scheduleReconnect();
|
|
285
|
+
}
|
|
192
286
|
});
|
|
193
287
|
|
|
194
288
|
ws.addEventListener("error", (errorEvent) => {
|
|
@@ -200,6 +294,7 @@ export class SlackSocketModeClient {
|
|
|
200
294
|
} catch (err) {
|
|
201
295
|
log.error({ err }, "Failed to create WebSocket connection");
|
|
202
296
|
this.ws = null;
|
|
297
|
+
this.connecting = false;
|
|
203
298
|
this.scheduleReconnect();
|
|
204
299
|
}
|
|
205
300
|
}
|
|
@@ -242,7 +337,7 @@ export class SlackSocketModeClient {
|
|
|
242
337
|
};
|
|
243
338
|
}
|
|
244
339
|
|
|
245
|
-
private handleMessage(raw: string): void {
|
|
340
|
+
private handleMessage(raw: string, originWs: WebSocket): void {
|
|
246
341
|
let envelope: {
|
|
247
342
|
envelope_id?: string;
|
|
248
343
|
type?: string;
|
|
@@ -272,32 +367,31 @@ export class SlackSocketModeClient {
|
|
|
272
367
|
return;
|
|
273
368
|
}
|
|
274
369
|
|
|
275
|
-
// ACK every envelope
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
this.ws.readyState === WebSocket.OPEN
|
|
280
|
-
) {
|
|
281
|
-
this.ws.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
|
|
370
|
+
// ACK every envelope on the socket that received it — never cross-ACK
|
|
371
|
+
// onto a different connection (e.g. after forceReconnect replaces this.ws).
|
|
372
|
+
if (envelope.envelope_id && originWs.readyState === WebSocket.OPEN) {
|
|
373
|
+
originWs.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
|
|
282
374
|
}
|
|
283
375
|
|
|
284
|
-
// Handle disconnect type: Slack asks us to reconnect
|
|
376
|
+
// Handle disconnect type: Slack asks us to reconnect.
|
|
377
|
+
// Only act if the requesting socket is still the active one —
|
|
378
|
+
// a stale socket's disconnect should not tear down a new connection.
|
|
285
379
|
if (envelope.type === "disconnect") {
|
|
286
380
|
log.info(
|
|
287
381
|
{ reason: envelope.reason },
|
|
288
382
|
"Slack requested disconnect, reconnecting",
|
|
289
383
|
);
|
|
290
|
-
if (this.ws) {
|
|
384
|
+
if (this.ws === originWs) {
|
|
291
385
|
try {
|
|
292
386
|
this.ws.close(1000, "server requested disconnect");
|
|
293
387
|
} catch {
|
|
294
388
|
// ignore
|
|
295
389
|
}
|
|
296
390
|
this.ws = null;
|
|
391
|
+
// Reconnect immediately (attempt 0 = minimal backoff)
|
|
392
|
+
this.reconnectAttempt = 0;
|
|
393
|
+
this.scheduleReconnect();
|
|
297
394
|
}
|
|
298
|
-
// Reconnect immediately (attempt 0 = minimal backoff)
|
|
299
|
-
this.reconnectAttempt = 0;
|
|
300
|
-
this.scheduleReconnect();
|
|
301
395
|
return;
|
|
302
396
|
}
|
|
303
397
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getLogger } from "./logger.js";
|
|
2
|
+
|
|
3
|
+
const log = getLogger("sleep-wake-detector");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detects system sleep/wake transitions using the epoch-gap technique:
|
|
7
|
+
* a periodic timer checks if the elapsed time between ticks far exceeds
|
|
8
|
+
* the expected interval, which indicates the process was suspended.
|
|
9
|
+
*/
|
|
10
|
+
export class SleepWakeDetector {
|
|
11
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
12
|
+
private lastTick: number = 0;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private onWake: () => void,
|
|
16
|
+
private intervalMs: number = 10_000,
|
|
17
|
+
private thresholdMultiplier: number = 2,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
start(): void {
|
|
21
|
+
this.stop();
|
|
22
|
+
this.lastTick = Date.now();
|
|
23
|
+
this.timer = setInterval(() => {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const elapsed = now - this.lastTick;
|
|
26
|
+
this.lastTick = now;
|
|
27
|
+
|
|
28
|
+
if (elapsed > this.intervalMs * this.thresholdMultiplier) {
|
|
29
|
+
log.info(
|
|
30
|
+
{ elapsedMs: elapsed, expectedMs: this.intervalMs },
|
|
31
|
+
"System wake detected (epoch gap)",
|
|
32
|
+
);
|
|
33
|
+
this.onWake();
|
|
34
|
+
}
|
|
35
|
+
}, this.intervalMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
stop(): void {
|
|
39
|
+
if (this.timer) {
|
|
40
|
+
clearInterval(this.timer);
|
|
41
|
+
this.timer = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|