@vellumai/assistant 0.3.3 → 0.3.4
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/README.md +8 -16
- package/package.json +1 -1
- package/src/__tests__/call-orchestrator.test.ts +321 -0
- package/src/__tests__/channel-approval-routes.test.ts +382 -124
- package/src/__tests__/channel-approvals.test.ts +51 -2
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +187 -0
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/daemon-lifecycle.test.ts +635 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +73 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/calls/call-orchestrator.ts +63 -11
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/messaging/SKILL.md +4 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +3 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +16 -0
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +52 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +38 -0
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +36 -13
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/ipc-contract.ts +6 -0
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +16 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +105 -502
- package/src/daemon/session-agent-loop.ts +5 -14
- package/src/daemon/session-runtime-assembly.ts +60 -44
- package/src/daemon/session.ts +8 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1019 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-worker.ts +7 -1
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +30 -1
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +1 -0
- package/src/memory/search/types.ts +2 -0
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/channel-approvals.ts +17 -3
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +15 -4
- package/src/runtime/routes/channel-routes.ts +172 -84
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +8 -1
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/executor.ts +10 -2
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/util/platform.ts +8 -1
- package/src/util/retry.ts +4 -4
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { getWorkspacePromptPath } from '../util/platform.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_USER_REFERENCE = 'my human';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the name/reference the assistant uses when referring to
|
|
8
|
+
* the human it represents in external communications.
|
|
9
|
+
*
|
|
10
|
+
* Reads the "Preferred name/reference:" field from the Onboarding
|
|
11
|
+
* Snapshot section of USER.md. Falls back to "my human" when the
|
|
12
|
+
* file is missing, unreadable, or the field is empty.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveUserReference(): string {
|
|
15
|
+
const userPath = getWorkspacePromptPath('USER.md');
|
|
16
|
+
if (!existsSync(userPath)) return DEFAULT_USER_REFERENCE;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const content = readFileSync(userPath, 'utf-8');
|
|
20
|
+
const match = content.match(/Preferred name\/reference:\s*(.+)/);
|
|
21
|
+
if (match && match[1].trim()) {
|
|
22
|
+
return match[1].trim();
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Fallback on any read error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return DEFAULT_USER_REFERENCE;
|
|
29
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Manifest of first-party Vellum skills. Fetched from GitHub at runtime so the assistant can discover and install new skills maintained by Vellum.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"skills": [
|
|
5
|
+
{
|
|
6
|
+
"id": "chatgpt-import",
|
|
7
|
+
"name": "ChatGPT Import",
|
|
8
|
+
"description": "Import conversation history from ChatGPT into Vellum",
|
|
9
|
+
"emoji": "\ud83d\udce5"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "deploy-fullstack-vercel",
|
|
13
|
+
"name": "Deploy Fullstack to Vercel",
|
|
14
|
+
"description": "Build and deploy a full-stack app (React frontend + Python/FastAPI backend) to Vercel as a serverless demo with seeded data",
|
|
15
|
+
"emoji": "\ud83d\ude80"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "document-writer",
|
|
19
|
+
"name": "Document Writer",
|
|
20
|
+
"description": "Create and edit long-form documents like blog posts, articles, essays, and reports using the built-in rich text editor",
|
|
21
|
+
"emoji": "\ud83d\udcdd"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "google-oauth-setup",
|
|
25
|
+
"name": "Google OAuth Setup",
|
|
26
|
+
"description": "Create Google Cloud OAuth credentials for Gmail integration using browser automation",
|
|
27
|
+
"emoji": "\ud83d\udd11",
|
|
28
|
+
"includes": ["browser", "public-ingress"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "slack-oauth-setup",
|
|
32
|
+
"name": "Slack OAuth Setup",
|
|
33
|
+
"description": "Create Slack App and OAuth credentials for Slack integration using browser automation",
|
|
34
|
+
"emoji": "\ud83d\udd11",
|
|
35
|
+
"includes": ["browser", "public-ingress"]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "telegram-setup",
|
|
39
|
+
"name": "Telegram Setup",
|
|
40
|
+
"description": "Connect a Telegram bot to the Vellum Assistant gateway with automated webhook registration and credential storage",
|
|
41
|
+
"emoji": "\ud83e\udd16",
|
|
42
|
+
"includes": ["public-ingress"]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "twilio-setup",
|
|
46
|
+
"name": "Twilio Setup",
|
|
47
|
+
"description": "Configure Twilio credentials and phone numbers for voice calls and SMS messaging",
|
|
48
|
+
"emoji": "\ud83d\udcf1",
|
|
49
|
+
"includes": ["public-ingress"]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -103,12 +103,17 @@ Before reporting success, confirm the guardian binding was actually created. Sen
|
|
|
103
103
|
|
|
104
104
|
### Step 8: Report Success
|
|
105
105
|
|
|
106
|
+
First, retrieve the bot identity by sending a `telegram_config` IPC message with `action: "get"` and reading the `botUsername` field from the response.
|
|
107
|
+
|
|
106
108
|
Summarize what was done:
|
|
109
|
+
- Bot identity: @{botUsername}
|
|
107
110
|
- Bot verified and credentials stored securely via daemon
|
|
108
111
|
- Webhook registration: handled automatically by the gateway
|
|
109
112
|
- Bot commands registered: /new, /guardian_verify
|
|
110
|
-
- Guardian identity verified
|
|
113
|
+
- Guardian identity: {verified | not configured}
|
|
114
|
+
- Guardian verification status: {verified via challenge | skipped}
|
|
111
115
|
- Routing configuration validated
|
|
116
|
+
- To re-check guardian status later, send `guardian_verification` with `action: "status"` and `channel: "telegram"`
|
|
112
117
|
|
|
113
118
|
The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the webhook secret changes later, the gateway's credential watcher will automatically re-register the webhook. If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart.
|
|
114
119
|
|
|
@@ -171,6 +171,44 @@ Confirm:
|
|
|
171
171
|
|
|
172
172
|
Tell the user: **"Twilio is configured. Your assistant's phone number is {phoneNumber}. This number is used for both voice calls and SMS messaging."**
|
|
173
173
|
|
|
174
|
+
## Step 5.5: Guardian Verification (SMS)
|
|
175
|
+
|
|
176
|
+
Now link the user's phone number as the trusted SMS guardian for this assistant. Tell the user: "Now let's verify your guardian identity for SMS. This links your phone number as the trusted guardian for SMS messaging."
|
|
177
|
+
|
|
178
|
+
1. Send the `guardian_verification` IPC message with `action: "create_challenge"` and `channel: "sms"`:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"type": "guardian_verification",
|
|
183
|
+
"action": "create_challenge",
|
|
184
|
+
"channel": "sms"
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the instruction to the user. It will look like: "Send `/guardian_verify <secret>` to your bot via SMS within 10 minutes."
|
|
189
|
+
|
|
190
|
+
3. Wait for the user to confirm they have sent the verification code via SMS to the assistant's phone number.
|
|
191
|
+
|
|
192
|
+
4. Check verification status by sending `guardian_verification` with `action: "status"` and `channel: "sms"`:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"type": "guardian_verification",
|
|
197
|
+
"action": "status",
|
|
198
|
+
"channel": "sms"
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
5. If `bound` is `true`: "Guardian verified! Your phone number is now the trusted SMS guardian."
|
|
203
|
+
|
|
204
|
+
6. If `bound` is `false` and the user claims they sent the code: "The verification doesn't appear to have succeeded. Let's generate a new challenge." Repeat from substep 1.
|
|
205
|
+
|
|
206
|
+
**Note:** Guardian verification is optional but recommended. If the user declines or wants to skip, proceed to Step 6 without blocking.
|
|
207
|
+
|
|
208
|
+
To re-check guardian status later, send `guardian_verification` with `action: "status"` and `channel: "sms"`.
|
|
209
|
+
|
|
210
|
+
Report the guardian verification result: **"Guardian identity: {verified | not configured}."**
|
|
211
|
+
|
|
174
212
|
## Step 6: Enable Features
|
|
175
213
|
|
|
176
214
|
Now that Twilio is configured, the user can enable the features that depend on it:
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages daemon-level authentication: session token lifecycle,
|
|
3
|
+
* per-socket auth state, and auth timeouts.
|
|
4
|
+
*/
|
|
5
|
+
import * as net from 'node:net';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
8
|
+
import { getSessionTokenPath } from '../util/platform.js';
|
|
9
|
+
import { hasNoAuthOverride } from './connection-policy.js';
|
|
10
|
+
import { getLogger } from '../util/logger.js';
|
|
11
|
+
|
|
12
|
+
const log = getLogger('auth-manager');
|
|
13
|
+
|
|
14
|
+
export const AUTH_TIMEOUT_MS = 5_000;
|
|
15
|
+
|
|
16
|
+
export class AuthManager {
|
|
17
|
+
private sessionToken = '';
|
|
18
|
+
private authenticatedSockets = new Set<net.Socket>();
|
|
19
|
+
private authTimeouts = new Map<net.Socket, ReturnType<typeof setTimeout>>();
|
|
20
|
+
|
|
21
|
+
/** Initialize the session token — reuse from disk or generate a new one. */
|
|
22
|
+
initToken(): void {
|
|
23
|
+
const tokenPath = getSessionTokenPath();
|
|
24
|
+
let existingToken: string | null = null;
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(tokenPath, 'utf-8').trim();
|
|
27
|
+
if (raw.length >= 32) existingToken = raw;
|
|
28
|
+
} catch { /* file doesn't exist yet */ }
|
|
29
|
+
|
|
30
|
+
if (existingToken) {
|
|
31
|
+
this.sessionToken = existingToken;
|
|
32
|
+
log.info({ tokenPath }, 'Reusing existing session token');
|
|
33
|
+
} else {
|
|
34
|
+
this.sessionToken = randomBytes(32).toString('hex');
|
|
35
|
+
writeFileSync(tokenPath, this.sessionToken, { mode: 0o600 });
|
|
36
|
+
chmodSync(tokenPath, 0o600);
|
|
37
|
+
log.info({ tokenPath }, 'New session token generated');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
isAuthenticated(socket: net.Socket): boolean {
|
|
42
|
+
return this.authenticatedSockets.has(socket);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns true if VELLUM_DAEMON_NOAUTH bypass is active. */
|
|
46
|
+
shouldAutoAuth(): boolean {
|
|
47
|
+
return hasNoAuthOverride();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
markAuthenticated(socket: net.Socket): void {
|
|
51
|
+
this.authenticatedSockets.add(socket);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Validate a token and authenticate the socket. Returns true on success. */
|
|
55
|
+
authenticate(socket: net.Socket, token: string): boolean {
|
|
56
|
+
if (token === this.sessionToken) {
|
|
57
|
+
this.authenticatedSockets.add(socket);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
log.warn('Client provided invalid auth token');
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Start the auth timeout for a newly connected socket. */
|
|
65
|
+
startTimeout(socket: net.Socket, onTimeout: () => void): void {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
if (!this.authenticatedSockets.has(socket)) {
|
|
68
|
+
log.warn('Client failed to authenticate within timeout, disconnecting');
|
|
69
|
+
onTimeout();
|
|
70
|
+
}
|
|
71
|
+
}, AUTH_TIMEOUT_MS);
|
|
72
|
+
this.authTimeouts.set(socket, timer);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clear the auth timeout (called when the first message arrives). */
|
|
76
|
+
clearTimeout(socket: net.Socket): void {
|
|
77
|
+
const timer = this.authTimeouts.get(socket);
|
|
78
|
+
if (timer) {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
this.authTimeouts.delete(socket);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Remove all auth state for a disconnected socket. */
|
|
85
|
+
cleanupSocket(socket: net.Socket): void {
|
|
86
|
+
this.clearTimeout(socket);
|
|
87
|
+
this.authenticatedSockets.delete(socket);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Tear down all auth state on server stop. */
|
|
91
|
+
cleanupAll(): void {
|
|
92
|
+
for (const timer of this.authTimeouts.values()) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
this.authTimeouts.clear();
|
|
96
|
+
this.authenticatedSockets.clear();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Iterate over authenticated sockets (for broadcasting). */
|
|
100
|
+
getAuthenticatedSockets(): Set<net.Socket> {
|
|
101
|
+
return this.authenticatedSockets;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -897,7 +897,14 @@ export class ComputerUseSession {
|
|
|
897
897
|
decision: UserDecision,
|
|
898
898
|
selectedPattern?: string,
|
|
899
899
|
selectedScope?: string,
|
|
900
|
+
decisionContext?: string,
|
|
900
901
|
): void {
|
|
901
|
-
this.prompter?.resolveConfirmation(
|
|
902
|
+
this.prompter?.resolveConfirmation(
|
|
903
|
+
requestId,
|
|
904
|
+
decision,
|
|
905
|
+
selectedPattern,
|
|
906
|
+
selectedScope,
|
|
907
|
+
decisionContext,
|
|
908
|
+
);
|
|
902
909
|
}
|
|
903
910
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File watchers and config reload logic extracted from DaemonServer.
|
|
3
|
+
* Watches workspace files (config, prompts), protected directory
|
|
4
|
+
* (trust rules, secret allowlist), and skills directories for changes.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, watch, type FSWatcher } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { getRootDir, getWorkspaceDir, getWorkspaceSkillsDir } from '../util/platform.js';
|
|
9
|
+
import { getConfig, invalidateConfigCache } from '../config/loader.js';
|
|
10
|
+
import { initializeProviders } from '../providers/registry.js';
|
|
11
|
+
import { clearCache as clearTrustCache } from '../permissions/trust-store.js';
|
|
12
|
+
import { resetAllowlist, validateAllowlistFile } from '../security/secret-allowlist.js';
|
|
13
|
+
import { clearEmbeddingBackendCache } from '../memory/embedding-backend.js';
|
|
14
|
+
import { DebouncerMap } from '../util/debounce.js';
|
|
15
|
+
import { getLogger } from '../util/logger.js';
|
|
16
|
+
|
|
17
|
+
const log = getLogger('config-watcher');
|
|
18
|
+
|
|
19
|
+
export class ConfigWatcher {
|
|
20
|
+
private watchers: FSWatcher[] = [];
|
|
21
|
+
private debounceTimers = new DebouncerMap({
|
|
22
|
+
defaultDelayMs: 200,
|
|
23
|
+
maxEntries: 1000,
|
|
24
|
+
protectedKeyPrefix: '__',
|
|
25
|
+
});
|
|
26
|
+
private suppressReload = false;
|
|
27
|
+
private lastFingerprint = '';
|
|
28
|
+
private lastRefreshTime = 0;
|
|
29
|
+
|
|
30
|
+
static readonly REFRESH_INTERVAL_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/** Expose the debounce timers so handlers can schedule debounced work. */
|
|
33
|
+
get timers(): DebouncerMap {
|
|
34
|
+
return this.debounceTimers;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get suppressConfigReload(): boolean {
|
|
38
|
+
return this.suppressReload;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
set suppressConfigReload(value: boolean) {
|
|
42
|
+
this.suppressReload = value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get lastConfigRefreshTime(): number {
|
|
46
|
+
return this.lastRefreshTime;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set lastConfigRefreshTime(value: number) {
|
|
50
|
+
this.lastRefreshTime = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compute a fingerprint of the current config for change detection. */
|
|
54
|
+
configFingerprint(config: ReturnType<typeof getConfig>): string {
|
|
55
|
+
return JSON.stringify(config);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Initialize the config fingerprint (call after first config load). */
|
|
59
|
+
initFingerprint(config: ReturnType<typeof getConfig>): void {
|
|
60
|
+
this.lastFingerprint = this.configFingerprint(config);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Update the fingerprint to match the current config. */
|
|
64
|
+
updateFingerprint(): void {
|
|
65
|
+
this.lastFingerprint = this.configFingerprint(getConfig());
|
|
66
|
+
this.lastRefreshTime = Date.now();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reload config from disk + secure storage, and refresh providers only
|
|
71
|
+
* when effective config values (including API keys) have changed.
|
|
72
|
+
* Returns true if config actually changed.
|
|
73
|
+
*/
|
|
74
|
+
refreshConfigFromSources(): boolean {
|
|
75
|
+
invalidateConfigCache();
|
|
76
|
+
const config = getConfig();
|
|
77
|
+
const fingerprint = this.configFingerprint(config);
|
|
78
|
+
if (fingerprint === this.lastFingerprint) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
clearTrustCache();
|
|
82
|
+
clearEmbeddingBackendCache();
|
|
83
|
+
const isFirstInit = this.lastFingerprint === '';
|
|
84
|
+
initializeProviders(config);
|
|
85
|
+
this.lastFingerprint = fingerprint;
|
|
86
|
+
return !isFirstInit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start all file watchers. `onSessionEvict` is called when watched
|
|
91
|
+
* files change and sessions need to be evicted for reload.
|
|
92
|
+
*/
|
|
93
|
+
start(onSessionEvict: () => void): void {
|
|
94
|
+
const workspaceDir = getWorkspaceDir();
|
|
95
|
+
const protectedDir = join(getRootDir(), 'protected');
|
|
96
|
+
|
|
97
|
+
const workspaceHandlers: Record<string, () => void> = {
|
|
98
|
+
'config.json': () => {
|
|
99
|
+
if (this.suppressReload) return;
|
|
100
|
+
try {
|
|
101
|
+
const changed = this.refreshConfigFromSources();
|
|
102
|
+
if (changed) onSessionEvict();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.error({ err, configPath: join(workspaceDir, 'config.json') }, 'Failed to reload config after file change. Previous config remains active.');
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
'SOUL.md': () => onSessionEvict(),
|
|
108
|
+
'IDENTITY.md': () => onSessionEvict(),
|
|
109
|
+
'USER.md': () => onSessionEvict(),
|
|
110
|
+
'LOOKS.md': () => onSessionEvict(),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const protectedHandlers: Record<string, () => void> = {
|
|
114
|
+
'trust.json': () => {
|
|
115
|
+
clearTrustCache();
|
|
116
|
+
},
|
|
117
|
+
'secret-allowlist.json': () => {
|
|
118
|
+
resetAllowlist();
|
|
119
|
+
try {
|
|
120
|
+
const errors = validateAllowlistFile();
|
|
121
|
+
if (errors && errors.length > 0) {
|
|
122
|
+
for (const e of errors) {
|
|
123
|
+
log.warn({ index: e.index, pattern: e.pattern }, `Invalid regex in secret-allowlist.json: ${e.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
log.warn({ err }, 'Failed to validate secret-allowlist.json');
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const watchDir = (dir: string, handlers: Record<string, () => void>, label: string): void => {
|
|
133
|
+
try {
|
|
134
|
+
const watcher = watch(dir, (_eventType, filename) => {
|
|
135
|
+
if (!filename) return;
|
|
136
|
+
const file = String(filename);
|
|
137
|
+
if (!handlers[file]) return;
|
|
138
|
+
this.debounceTimers.schedule(`file:${file}`, () => {
|
|
139
|
+
log.info({ file }, 'File changed, reloading');
|
|
140
|
+
handlers[file]();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
this.watchers.push(watcher);
|
|
144
|
+
log.info({ dir }, `Watching ${label}`);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
log.warn({ err, dir }, `Failed to watch ${label}. Hot-reload will be unavailable.`);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
watchDir(workspaceDir, workspaceHandlers, 'workspace directory for config/prompt changes');
|
|
151
|
+
if (existsSync(protectedDir)) {
|
|
152
|
+
watchDir(protectedDir, protectedHandlers, 'protected directory for trust/allowlist changes');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.startSkillsWatchers(onSessionEvict);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
stop(): void {
|
|
159
|
+
this.debounceTimers.cancelAll();
|
|
160
|
+
for (const watcher of this.watchers) {
|
|
161
|
+
watcher.close();
|
|
162
|
+
}
|
|
163
|
+
this.watchers = [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private startSkillsWatchers(onSessionEvict: () => void): void {
|
|
167
|
+
const skillsDir = getWorkspaceSkillsDir();
|
|
168
|
+
if (!existsSync(skillsDir)) return;
|
|
169
|
+
|
|
170
|
+
const scheduleSkillsReload = (file: string): void => {
|
|
171
|
+
this.debounceTimers.schedule(`skills:${file}`, () => {
|
|
172
|
+
log.info({ file }, 'Skill file changed, reloading');
|
|
173
|
+
onSessionEvict();
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const recursiveWatcher = watch(skillsDir, { recursive: true }, (_eventType, filename) => {
|
|
179
|
+
scheduleSkillsReload(filename ? String(filename) : '(unknown)');
|
|
180
|
+
});
|
|
181
|
+
this.watchers.push(recursiveWatcher);
|
|
182
|
+
log.info({ dir: skillsDir }, 'Watching skills directory recursively');
|
|
183
|
+
return;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
log.info({ err, dir: skillsDir }, 'Recursive skills watch unavailable; using per-directory watchers');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const childWatchers = new Map<string, FSWatcher>();
|
|
189
|
+
|
|
190
|
+
const watchDir = (dirPath: string, onChange: (filename: string) => void): FSWatcher | null => {
|
|
191
|
+
try {
|
|
192
|
+
const watcher = watch(dirPath, (_eventType, filename) => {
|
|
193
|
+
onChange(filename ? String(filename) : '(unknown)');
|
|
194
|
+
});
|
|
195
|
+
this.watchers.push(watcher);
|
|
196
|
+
return watcher;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
log.warn({ err, dirPath }, 'Failed to watch skills directory');
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const removeWatcher = (watcher: FSWatcher): void => {
|
|
204
|
+
const idx = this.watchers.indexOf(watcher);
|
|
205
|
+
if (idx !== -1) {
|
|
206
|
+
this.watchers.splice(idx, 1);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const refreshChildWatchers = (): void => {
|
|
211
|
+
const nextChildDirs = new Set<string>();
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
if (!entry.isDirectory()) continue;
|
|
217
|
+
const childDir = join(skillsDir, entry.name);
|
|
218
|
+
nextChildDirs.add(childDir);
|
|
219
|
+
|
|
220
|
+
if (childWatchers.has(childDir)) continue;
|
|
221
|
+
|
|
222
|
+
const watcher = watchDir(childDir, (filename) => {
|
|
223
|
+
const label = filename === '(unknown)' ? entry.name : `${entry.name}/${filename}`;
|
|
224
|
+
scheduleSkillsReload(label);
|
|
225
|
+
});
|
|
226
|
+
if (watcher) {
|
|
227
|
+
childWatchers.set(childDir, watcher);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
log.warn({ err, skillsDir }, 'Failed to enumerate skill directories');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const [childDir, watcher] of childWatchers.entries()) {
|
|
236
|
+
if (nextChildDirs.has(childDir)) continue;
|
|
237
|
+
watcher.close();
|
|
238
|
+
childWatchers.delete(childDir);
|
|
239
|
+
removeWatcher(watcher);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const rootWatcher = watchDir(skillsDir, (filename) => {
|
|
244
|
+
scheduleSkillsReload(filename);
|
|
245
|
+
refreshChildWatchers();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!rootWatcher) return;
|
|
249
|
+
|
|
250
|
+
refreshChildWatchers();
|
|
251
|
+
log.info({ dir: skillsDir }, 'Watching skills directory with non-recursive fallback');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -628,20 +628,36 @@ export async function handleIngressConfig(
|
|
|
628
628
|
triggerGatewayReconcile(effectiveUrl);
|
|
629
629
|
|
|
630
630
|
// Best-effort Twilio webhook reconciliation: when ingress is being
|
|
631
|
-
// enabled/updated and
|
|
631
|
+
// enabled/updated and Twilio numbers are assigned with valid credentials,
|
|
632
632
|
// push the new webhook URLs to Twilio so calls and SMS route correctly.
|
|
633
633
|
if (isEnabled && hasTwilioCredentials()) {
|
|
634
634
|
const currentConfig = loadRawConfig();
|
|
635
635
|
const smsConfig = (currentConfig?.sms ?? {}) as Record<string, unknown>;
|
|
636
|
-
const
|
|
637
|
-
|
|
636
|
+
const assignedNumbers = new Set<string>();
|
|
637
|
+
const legacyNumber = (smsConfig.phoneNumber as string) ?? '';
|
|
638
|
+
if (legacyNumber) assignedNumbers.add(legacyNumber);
|
|
639
|
+
|
|
640
|
+
const assistantPhoneNumbers = smsConfig.assistantPhoneNumbers;
|
|
641
|
+
if (assistantPhoneNumbers && typeof assistantPhoneNumbers === 'object' && !Array.isArray(assistantPhoneNumbers)) {
|
|
642
|
+
for (const number of Object.values(assistantPhoneNumbers as Record<string, unknown>)) {
|
|
643
|
+
if (typeof number === 'string' && number) {
|
|
644
|
+
assignedNumbers.add(number);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (assignedNumbers.size > 0) {
|
|
638
650
|
const acctSid = getSecureKey('credential:twilio:account_sid')!;
|
|
639
651
|
const acctToken = getSecureKey('credential:twilio:auth_token')!;
|
|
640
|
-
// Fire-and-forget: webhook sync failure must not block the ingress save
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
652
|
+
// Fire-and-forget: webhook sync failure must not block the ingress save.
|
|
653
|
+
// Reconcile every assigned number so assistant-scoped mappings do not
|
|
654
|
+
// retain stale Twilio webhook URLs after ingress URL changes.
|
|
655
|
+
for (const assignedNumber of assignedNumbers) {
|
|
656
|
+
syncTwilioWebhooks(assignedNumber, acctSid, acctToken, currentConfig as IngressConfig)
|
|
657
|
+
.catch(() => {
|
|
658
|
+
// Already logged inside syncTwilioWebhooks
|
|
659
|
+
});
|
|
660
|
+
}
|
|
645
661
|
}
|
|
646
662
|
}
|
|
647
663
|
} else {
|
|
@@ -1484,12 +1500,12 @@ export function handleGuardianVerification(
|
|
|
1484
1500
|
socket: net.Socket,
|
|
1485
1501
|
ctx: HandlerContext,
|
|
1486
1502
|
): void {
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
const channel = msg.channel ?? 'telegram';
|
|
1503
|
+
// Use the assistant ID from the request when available; fall back to
|
|
1504
|
+
// 'self' for backward compatibility with single-assistant mode.
|
|
1505
|
+
const assistantId = msg.assistantId ?? 'self';
|
|
1506
|
+
const channel = msg.channel ?? 'telegram';
|
|
1492
1507
|
|
|
1508
|
+
try {
|
|
1493
1509
|
if (msg.action === 'create_challenge') {
|
|
1494
1510
|
const result = createVerificationChallenge(assistantId, channel, msg.sessionId);
|
|
1495
1511
|
|
|
@@ -1498,6 +1514,7 @@ export function handleGuardianVerification(
|
|
|
1498
1514
|
success: true,
|
|
1499
1515
|
secret: result.secret,
|
|
1500
1516
|
instruction: result.instruction,
|
|
1517
|
+
channel,
|
|
1501
1518
|
});
|
|
1502
1519
|
} else if (msg.action === 'status') {
|
|
1503
1520
|
const binding = getGuardianBinding(assistantId, channel);
|
|
@@ -1506,6 +1523,9 @@ export function handleGuardianVerification(
|
|
|
1506
1523
|
success: true,
|
|
1507
1524
|
bound: binding !== null,
|
|
1508
1525
|
guardianExternalUserId: binding?.guardianExternalUserId,
|
|
1526
|
+
channel,
|
|
1527
|
+
assistantId,
|
|
1528
|
+
guardianDeliveryChatId: binding?.guardianDeliveryChatId,
|
|
1509
1529
|
});
|
|
1510
1530
|
} else if (msg.action === 'revoke') {
|
|
1511
1531
|
revokeGuardianBinding(assistantId, channel);
|
|
@@ -1513,12 +1533,14 @@ export function handleGuardianVerification(
|
|
|
1513
1533
|
type: 'guardian_verification_response',
|
|
1514
1534
|
success: true,
|
|
1515
1535
|
bound: false,
|
|
1536
|
+
channel,
|
|
1516
1537
|
});
|
|
1517
1538
|
} else {
|
|
1518
1539
|
ctx.send(socket, {
|
|
1519
1540
|
type: 'guardian_verification_response',
|
|
1520
1541
|
success: false,
|
|
1521
1542
|
error: `Unknown action: ${String(msg.action)}`,
|
|
1543
|
+
channel,
|
|
1522
1544
|
});
|
|
1523
1545
|
}
|
|
1524
1546
|
} catch (err) {
|
|
@@ -1528,6 +1550,7 @@ export function handleGuardianVerification(
|
|
|
1528
1550
|
type: 'guardian_verification_response',
|
|
1529
1551
|
success: false,
|
|
1530
1552
|
error: message,
|
|
1553
|
+
channel,
|
|
1531
1554
|
});
|
|
1532
1555
|
}
|
|
1533
1556
|
}
|
|
@@ -7,7 +7,7 @@ import { resolveSkillStates } from '../../config/skill-state.js';
|
|
|
7
7
|
import { getWorkspaceSkillsDir } from '../../util/platform.js';
|
|
8
8
|
import { clawhubInstall, clawhubUpdate, clawhubSearch, clawhubCheckUpdates, clawhubInspect, type ClawhubSearchResultItem } from '../../skills/clawhub.js';
|
|
9
9
|
import { removeSkillsIndexEntry, deleteManagedSkill, validateManagedSkillId } from '../../skills/managed-store.js';
|
|
10
|
-
import { listCatalogEntries, installFromVellumCatalog } from '../../tools/skills/vellum-catalog.js';
|
|
10
|
+
import { listCatalogEntries, installFromVellumCatalog, checkVellumSkill } from '../../tools/skills/vellum-catalog.js';
|
|
11
11
|
import type {
|
|
12
12
|
SkillDetailRequest,
|
|
13
13
|
SkillsEnableRequest,
|
|
@@ -188,14 +188,13 @@ export async function handleSkillsInstall(
|
|
|
188
188
|
): Promise<void> {
|
|
189
189
|
try {
|
|
190
190
|
// Check if the slug matches a vellum-skills catalog entry first
|
|
191
|
-
const
|
|
192
|
-
const isVellumSkill = catalogEntries.some((e) => e.id === msg.slug);
|
|
191
|
+
const isVellumSkill = await checkVellumSkill(msg.slug);
|
|
193
192
|
|
|
194
193
|
let skillId: string;
|
|
195
194
|
|
|
196
195
|
if (isVellumSkill) {
|
|
197
|
-
// Install from vellum-skills catalog (
|
|
198
|
-
const result = installFromVellumCatalog(msg.slug);
|
|
196
|
+
// Install from vellum-skills catalog (remote with bundled fallback)
|
|
197
|
+
const result = await installFromVellumCatalog(msg.slug);
|
|
199
198
|
if (!result.success) {
|
|
200
199
|
ctx.send(socket, {
|
|
201
200
|
type: 'skills_operation_response',
|
|
@@ -424,8 +423,8 @@ export async function handleSkillsSearch(
|
|
|
424
423
|
ctx: HandlerContext,
|
|
425
424
|
): Promise<void> {
|
|
426
425
|
try {
|
|
427
|
-
// Search vellum-skills catalog
|
|
428
|
-
const catalogEntries = listCatalogEntries();
|
|
426
|
+
// Search vellum-skills catalog (remote with bundled fallback)
|
|
427
|
+
const catalogEntries = await listCatalogEntries();
|
|
429
428
|
const query = (msg.query ?? '').toLowerCase();
|
|
430
429
|
const matchingCatalog = catalogEntries.filter((e) => {
|
|
431
430
|
if (!query) return true;
|
|
@@ -591,6 +591,12 @@ export interface GuardianVerificationResponse {
|
|
|
591
591
|
/** Present when action is 'status'. */
|
|
592
592
|
bound?: boolean;
|
|
593
593
|
guardianExternalUserId?: string;
|
|
594
|
+
/** The channel this status pertains to (e.g. "telegram", "sms"). Present when action is 'status'. */
|
|
595
|
+
channel?: string;
|
|
596
|
+
/** The assistant ID scoped to this status. Present when action is 'status'. */
|
|
597
|
+
assistantId?: string;
|
|
598
|
+
/** The delivery chat ID for the guardian (e.g. Telegram chat ID). Present when action is 'status' and bound is true. */
|
|
599
|
+
guardianDeliveryChatId?: string;
|
|
594
600
|
error?: string;
|
|
595
601
|
}
|
|
596
602
|
|