@vellumai/vellum-gateway 0.7.0 → 0.7.2
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -324,6 +324,11 @@ export interface SuggestTrustRuleRequest {
|
|
|
324
324
|
directoryScopeOptions?: DirectoryScopeOption[];
|
|
325
325
|
currentThreshold: string; // "low" | "medium" | "high"
|
|
326
326
|
intent: "auto_approve" | "escalate";
|
|
327
|
+
existingRule?: {
|
|
328
|
+
id: string;
|
|
329
|
+
pattern: string;
|
|
330
|
+
risk: string;
|
|
331
|
+
};
|
|
327
332
|
}
|
|
328
333
|
|
|
329
334
|
export interface SuggestTrustRuleResponse {
|
|
@@ -343,10 +348,9 @@ export interface SuggestTrustRuleResponse {
|
|
|
343
348
|
export async function ipcSuggestTrustRule(
|
|
344
349
|
params: SuggestTrustRuleRequest,
|
|
345
350
|
): Promise<SuggestTrustRuleResponse> {
|
|
346
|
-
const result = await ipcCallAssistant(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
);
|
|
351
|
+
const result = await ipcCallAssistant("suggest_trust_rule", {
|
|
352
|
+
body: params,
|
|
353
|
+
} as unknown as Record<string, unknown>);
|
|
350
354
|
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
351
355
|
throw new Error("ipcSuggestTrustRule: unexpected response shape");
|
|
352
356
|
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* IPC route definitions for contact reads.
|
|
2
|
+
* IPC route definitions for contact reads and writes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Read methods expose gateway-owned contact data to the assistant daemon.
|
|
5
|
+
* The `create_contact` write method upserts a contact+channel via the
|
|
6
|
+
* assistant DB proxy (raw SQL), so the gateway owns the write path.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
|
|
11
|
+
import {
|
|
12
|
+
assistantDbQuery,
|
|
13
|
+
assistantDbRun,
|
|
14
|
+
} from "../db/assistant-db-proxy.js";
|
|
10
15
|
import { ContactStore } from "../db/contact-store.js";
|
|
16
|
+
import { getLogger } from "../logger.js";
|
|
11
17
|
import type { IpcRoute } from "./server.js";
|
|
12
18
|
|
|
19
|
+
const log = getLogger("contact-handlers");
|
|
20
|
+
|
|
13
21
|
let store: ContactStore | null = null;
|
|
14
22
|
|
|
15
23
|
function getStore(): ContactStore {
|
|
@@ -19,6 +27,13 @@ function getStore(): ContactStore {
|
|
|
19
27
|
return store;
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
const CreateContactParamsSchema = z.object({
|
|
31
|
+
channelType: z.string().min(1),
|
|
32
|
+
address: z.string().min(1),
|
|
33
|
+
role: z.enum(["guardian", "trusted-contact", "unknown"]).optional(),
|
|
34
|
+
displayName: z.string().optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
22
37
|
const GetContactParamsSchema = z.object({
|
|
23
38
|
contactId: z.string(),
|
|
24
39
|
});
|
|
@@ -64,4 +79,74 @@ export const contactRoutes: IpcRoute[] = [
|
|
|
64
79
|
return getStore().getChannelsForContact(contactId);
|
|
65
80
|
},
|
|
66
81
|
},
|
|
82
|
+
{
|
|
83
|
+
method: "create_contact",
|
|
84
|
+
schema: CreateContactParamsSchema,
|
|
85
|
+
handler: async (params?: Record<string, unknown>) => {
|
|
86
|
+
const { channelType, address, role, displayName } =
|
|
87
|
+
CreateContactParamsSchema.parse(params);
|
|
88
|
+
|
|
89
|
+
const normalizedAddress = address.toLowerCase().trim();
|
|
90
|
+
const effectiveDisplayName = displayName ?? normalizedAddress;
|
|
91
|
+
// Map prompt roles to valid ContactRole values ("guardian" | "contact").
|
|
92
|
+
const effectiveRole: string =
|
|
93
|
+
role === "guardian" ? "guardian" : "contact";
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
|
|
96
|
+
// Check if a channel with this (type, address) already exists.
|
|
97
|
+
const existing = await assistantDbQuery<{
|
|
98
|
+
channelId: string;
|
|
99
|
+
contactId: string;
|
|
100
|
+
}>(
|
|
101
|
+
`SELECT cc.id AS channelId, cc.contact_id AS contactId
|
|
102
|
+
FROM contact_channels cc
|
|
103
|
+
WHERE cc.type = ? AND cc.address = ?
|
|
104
|
+
LIMIT 1`,
|
|
105
|
+
[channelType, normalizedAddress],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (existing.length > 0) {
|
|
109
|
+
const { channelId, contactId } = existing[0];
|
|
110
|
+
log.info(
|
|
111
|
+
{ channelType, address: normalizedAddress, contactId, channelId },
|
|
112
|
+
"create_contact: channel already exists, returning existing record",
|
|
113
|
+
);
|
|
114
|
+
return { contactId, channelId };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create a new contact + channel.
|
|
118
|
+
// Two separate INSERTs — use a compensating DELETE on channel failure.
|
|
119
|
+
const contactId = crypto.randomUUID();
|
|
120
|
+
const channelId = crypto.randomUUID();
|
|
121
|
+
|
|
122
|
+
await assistantDbRun(
|
|
123
|
+
`INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
|
|
124
|
+
VALUES (?, ?, ?, 'human', ?, ?)`,
|
|
125
|
+
[contactId, effectiveDisplayName, effectiveRole, now, now],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await assistantDbRun(
|
|
130
|
+
`INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
|
|
131
|
+
VALUES (?, ?, ?, ?, 1, 'unverified', 'allow', 0, ?, ?)`,
|
|
132
|
+
[channelId, contactId, channelType, normalizedAddress, now, now],
|
|
133
|
+
);
|
|
134
|
+
} catch (channelErr) {
|
|
135
|
+
// Compensating delete — remove the orphaned contact row.
|
|
136
|
+
log.error(
|
|
137
|
+
{ channelErr, contactId, channelType, address: normalizedAddress },
|
|
138
|
+
"create_contact: channel INSERT failed, rolling back contact row",
|
|
139
|
+
);
|
|
140
|
+
await assistantDbRun("DELETE FROM contacts WHERE id = ?", [contactId]);
|
|
141
|
+
throw channelErr;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
log.info(
|
|
145
|
+
{ channelType, address: normalizedAddress, contactId, channelId, role: effectiveRole },
|
|
146
|
+
"create_contact: created new contact + channel",
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return { contactId, channelId };
|
|
150
|
+
},
|
|
151
|
+
},
|
|
67
152
|
];
|
|
@@ -18,6 +18,7 @@ import type { IpcRoute } from "./server.js";
|
|
|
18
18
|
const GLOBAL_DEFAULTS = {
|
|
19
19
|
interactive: "medium",
|
|
20
20
|
autonomous: "low",
|
|
21
|
+
headless: "none",
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
const GetConversationThresholdSchema = z.object({
|
|
@@ -45,6 +46,7 @@ export const thresholdRoutes: IpcRoute[] = [
|
|
|
45
46
|
return {
|
|
46
47
|
interactive: row.interactive,
|
|
47
48
|
autonomous: row.autonomous,
|
|
49
|
+
headless: row.headless,
|
|
48
50
|
};
|
|
49
51
|
},
|
|
50
52
|
},
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* assistant DB doesn't exist yet.
|
|
9
9
|
*
|
|
10
10
|
* This module polls the assistant IPC health route and, once the assistant
|
|
11
|
-
* is ready, runs
|
|
11
|
+
* is ready, runs data migrations and other deferred tasks. It is awaited
|
|
12
|
+
* during startup — the HTTP server does not start until this completes,
|
|
13
|
+
* preventing auth traffic from racing with data migrations.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import type { Database } from "bun:sqlite";
|
|
@@ -28,7 +30,7 @@ function getRawDb(drizzleDb: GatewayDb): Database {
|
|
|
28
30
|
return (drizzleDb as unknown as { $client: Database }).$client;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
async function waitForAssistant(): Promise<boolean> {
|
|
33
|
+
export async function waitForAssistant(): Promise<boolean> {
|
|
32
34
|
const deadline = Date.now() + MAX_WAIT_MS;
|
|
33
35
|
|
|
34
36
|
while (Date.now() < deadline) {
|
|
@@ -50,7 +52,7 @@ async function waitForAssistant(): Promise<boolean> {
|
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
54
|
* Wait for the assistant runtime to become healthy, then run deferred
|
|
53
|
-
* startup tasks.
|
|
55
|
+
* startup tasks. Awaited at startup — blocks Bun.serve().
|
|
54
56
|
*/
|
|
55
57
|
export async function runPostAssistantReady(): Promise<void> {
|
|
56
58
|
const ready = await waitForAssistant();
|
|
@@ -765,13 +765,13 @@ describe("unknown commands", () => {
|
|
|
765
765
|
expect(result.matchType).toBe("registry");
|
|
766
766
|
});
|
|
767
767
|
|
|
768
|
-
test("unknown command with --help →
|
|
768
|
+
test("unknown command with --help → unknown risk", async () => {
|
|
769
769
|
const result = await classifier.classify({
|
|
770
770
|
command: "mycustomtool --help",
|
|
771
771
|
toolName: "bash",
|
|
772
772
|
});
|
|
773
|
-
expect(result.riskLevel).toBe("
|
|
774
|
-
expect(result.matchType).toBe("
|
|
773
|
+
expect(result.riskLevel).toBe("unknown");
|
|
774
|
+
expect(result.matchType).toBe("unknown");
|
|
775
775
|
});
|
|
776
776
|
|
|
777
777
|
test("--help after -- is positional, not help mode", async () => {
|
|
@@ -781,6 +781,38 @@ describe("unknown commands", () => {
|
|
|
781
781
|
});
|
|
782
782
|
expect(result.riskLevel).toBe("high");
|
|
783
783
|
});
|
|
784
|
+
|
|
785
|
+
test("value-taking flags do not trigger help shortcut", async () => {
|
|
786
|
+
const result = await classifier.classify({
|
|
787
|
+
command: "tar -f --help /etc/passwd",
|
|
788
|
+
toolName: "bash",
|
|
789
|
+
});
|
|
790
|
+
expect(result.riskLevel).toBe("medium");
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("commands without arg schema do not use help shortcut", async () => {
|
|
794
|
+
const result = await classifier.classify({
|
|
795
|
+
command: "bash --rcfile --help -c 'rm -rf /'",
|
|
796
|
+
toolName: "bash",
|
|
797
|
+
});
|
|
798
|
+
expect(result.riskLevel).toBe("high");
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("bundled short flags with value flag do not trigger help shortcut", async () => {
|
|
802
|
+
const result = await classifier.classify({
|
|
803
|
+
command: "tar -xf --help /etc/passwd",
|
|
804
|
+
toolName: "bash",
|
|
805
|
+
});
|
|
806
|
+
expect(result.riskLevel).toBe("medium");
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test("known command without argSchema + --help uses base risk", async () => {
|
|
810
|
+
const result = await classifier.classify({
|
|
811
|
+
command: "kill --help",
|
|
812
|
+
toolName: "bash",
|
|
813
|
+
});
|
|
814
|
+
expect(result.riskLevel).toBe("high");
|
|
815
|
+
});
|
|
784
816
|
});
|
|
785
817
|
|
|
786
818
|
// ── Variable expansion escalation ────────────────────────────────────────────
|
|
@@ -923,30 +955,6 @@ describe("assistant subcommand classification", () => {
|
|
|
923
955
|
expect(result.riskLevel).toBe("high");
|
|
924
956
|
});
|
|
925
957
|
|
|
926
|
-
test("assistant trust remove → high", async () => {
|
|
927
|
-
const result = await classifier.classify({
|
|
928
|
-
command: "assistant trust remove",
|
|
929
|
-
toolName: "bash",
|
|
930
|
-
});
|
|
931
|
-
expect(result.riskLevel).toBe("high");
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
test("assistant trust add → high", async () => {
|
|
935
|
-
const result = await classifier.classify({
|
|
936
|
-
command: "assistant trust add",
|
|
937
|
-
toolName: "bash",
|
|
938
|
-
});
|
|
939
|
-
expect(result.riskLevel).toBe("high");
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
test("assistant trust update → high", async () => {
|
|
943
|
-
const result = await classifier.classify({
|
|
944
|
-
command: "assistant trust update",
|
|
945
|
-
toolName: "bash",
|
|
946
|
-
});
|
|
947
|
-
expect(result.riskLevel).toBe("high");
|
|
948
|
-
});
|
|
949
|
-
|
|
950
958
|
test("assistant config set → low", async () => {
|
|
951
959
|
const result = await classifier.classify({
|
|
952
960
|
command: "assistant config set key value",
|
|
@@ -189,14 +189,48 @@ const RM_BENIGN_FLAGS = new Set([
|
|
|
189
189
|
]);
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
|
-
* Returns true when
|
|
192
|
+
* Returns true when args contain a top-level help option that is not consumed
|
|
193
|
+
* as a value by another flag.
|
|
193
194
|
*
|
|
194
|
-
* This intentionally ignores
|
|
195
|
-
*
|
|
195
|
+
* This intentionally ignores:
|
|
196
|
+
* - any `--help` token that appears after `--` (positional mode), and
|
|
197
|
+
* - any `--help` token consumed as the value of a known value-taking flag.
|
|
198
|
+
*
|
|
199
|
+
* Help-mode shortcuts are only enabled for commands with an arg schema.
|
|
196
200
|
*/
|
|
197
|
-
function
|
|
198
|
-
|
|
201
|
+
function hasStandaloneHelpFlag(args: string[], schema?: ArgSchema): boolean {
|
|
202
|
+
if (!schema) return false;
|
|
203
|
+
|
|
204
|
+
const valueFlags = new Set(schema.valueFlags ?? []);
|
|
205
|
+
for (let i = 0; i < args.length; i++) {
|
|
206
|
+
const arg = args[i]!;
|
|
199
207
|
if (arg === "--") return false;
|
|
208
|
+
|
|
209
|
+
// Value-taking flags consume the next token (if present), so that token
|
|
210
|
+
// must not be interpreted as a standalone top-level option.
|
|
211
|
+
if (valueFlags.has(arg)) {
|
|
212
|
+
if (i + 1 < args.length) i++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Short-flag bundles: -xf where -f is a value flag. POSIX convention
|
|
217
|
+
// places the value-consuming flag last in a bundle, so check the final
|
|
218
|
+
// character. If it matches a known short value flag, the next token is
|
|
219
|
+
// its value and must be skipped.
|
|
220
|
+
if (arg.startsWith("-") && !arg.startsWith("--") && arg.length > 2) {
|
|
221
|
+
const lastChar = arg[arg.length - 1];
|
|
222
|
+
if (lastChar && valueFlags.has(`-${lastChar}`)) {
|
|
223
|
+
if (i + 1 < args.length) i++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (arg.startsWith("-") && arg.includes("=")) {
|
|
229
|
+
const eqIdx = arg.indexOf("=");
|
|
230
|
+
const flagPart = arg.slice(0, eqIdx);
|
|
231
|
+
if (valueFlags.has(flagPart)) continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
200
234
|
if (arg === "--help" || arg.startsWith("--help=")) return true;
|
|
201
235
|
}
|
|
202
236
|
return false;
|
|
@@ -280,14 +314,6 @@ export function classifySegment(
|
|
|
280
314
|
}
|
|
281
315
|
|
|
282
316
|
if (!spec) {
|
|
283
|
-
// Unknown command with --help is just viewing help → low risk
|
|
284
|
-
if (hasHelpFlag(segment.args)) {
|
|
285
|
-
return {
|
|
286
|
-
risk: "low",
|
|
287
|
-
reason: `${segment.program} help output`,
|
|
288
|
-
matchType: "registry",
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
317
|
return {
|
|
292
318
|
risk: "unknown",
|
|
293
319
|
reason: `Unknown command: ${segment.program}`,
|
|
@@ -299,7 +325,11 @@ export function classifySegment(
|
|
|
299
325
|
// Commands WITH subcommands (e.g. `assistant`) skip this — their
|
|
300
326
|
// subcommand resolution may assign elevated risk that --help must
|
|
301
327
|
// not bypass.
|
|
302
|
-
if (
|
|
328
|
+
if (
|
|
329
|
+
!spec.subcommands &&
|
|
330
|
+
!spec.isWrapper &&
|
|
331
|
+
hasStandaloneHelpFlag(segment.args, spec.argSchema)
|
|
332
|
+
) {
|
|
303
333
|
return {
|
|
304
334
|
risk: "low",
|
|
305
335
|
reason: `${segment.program} help output`,
|
|
@@ -73,6 +73,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
|
|
|
73
73
|
"channel-verification-sessions cancel",
|
|
74
74
|
"channel-verification-sessions revoke",
|
|
75
75
|
"clients",
|
|
76
|
+
"clients disconnect",
|
|
76
77
|
"clients list",
|
|
77
78
|
"completions",
|
|
78
79
|
"config",
|
|
@@ -85,6 +86,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
|
|
|
85
86
|
"contacts list",
|
|
86
87
|
"contacts get",
|
|
87
88
|
"contacts merge",
|
|
89
|
+
"contacts prompt",
|
|
88
90
|
"contacts upsert",
|
|
89
91
|
"contacts channels",
|
|
90
92
|
"contacts channels update-status",
|
|
@@ -108,6 +110,8 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
|
|
|
108
110
|
"credential-execution",
|
|
109
111
|
"credential-execution grants",
|
|
110
112
|
"credential-execution grants list",
|
|
113
|
+
"pending",
|
|
114
|
+
"pending list",
|
|
111
115
|
"credential-execution grants revoke",
|
|
112
116
|
"credential-execution audit",
|
|
113
117
|
"credential-execution audit list",
|
|
@@ -118,6 +122,9 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
|
|
|
118
122
|
"credentials delete",
|
|
119
123
|
"credentials inspect",
|
|
120
124
|
"credentials reveal",
|
|
125
|
+
"gateway",
|
|
126
|
+
"gateway logs",
|
|
127
|
+
"gateway logs tail",
|
|
121
128
|
"image-generation",
|
|
122
129
|
"image-generation generate",
|
|
123
130
|
"inference",
|
|
@@ -212,9 +219,6 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
|
|
|
212
219
|
"task queue run",
|
|
213
220
|
"trust",
|
|
214
221
|
"trust list",
|
|
215
|
-
"trust add",
|
|
216
|
-
"trust update",
|
|
217
|
-
"trust remove",
|
|
218
222
|
"tts",
|
|
219
223
|
"tts synthesize",
|
|
220
224
|
"ui",
|
|
@@ -336,22 +340,6 @@ const riskOverrides: AssistantRiskOverride[] = [
|
|
|
336
340
|
risk: "high",
|
|
337
341
|
reason: "Deletes API key material",
|
|
338
342
|
},
|
|
339
|
-
{
|
|
340
|
-
path: "trust add",
|
|
341
|
-
risk: "high",
|
|
342
|
-
reason: "Creates trust rule that affects auto-approve behavior",
|
|
343
|
-
},
|
|
344
|
-
{
|
|
345
|
-
path: "trust update",
|
|
346
|
-
risk: "high",
|
|
347
|
-
reason: "Modifies trust rule risk level or description",
|
|
348
|
-
},
|
|
349
|
-
{
|
|
350
|
-
path: "trust remove",
|
|
351
|
-
risk: "high",
|
|
352
|
-
reason: "Removes trust rule",
|
|
353
|
-
},
|
|
354
|
-
|
|
355
343
|
// Destructive assistant-state operations
|
|
356
344
|
{
|
|
357
345
|
path: "backup restore",
|
|
@@ -390,6 +378,7 @@ const riskOverrides: AssistantRiskOverride[] = [
|
|
|
390
378
|
{ path: "channel-verification-sessions revoke", risk: "low" },
|
|
391
379
|
{ path: "config set", risk: "low" },
|
|
392
380
|
{ path: "contacts merge", risk: "medium" },
|
|
381
|
+
{ path: "contacts prompt", risk: "medium" },
|
|
393
382
|
{ path: "contacts upsert", risk: "medium" },
|
|
394
383
|
{ path: "contacts channels update-status", risk: "medium" },
|
|
395
384
|
{ path: "contacts invites create", risk: "high" },
|
|
@@ -474,18 +474,6 @@ describe("command-registry", () => {
|
|
|
474
474
|
test("assistant trust (bare) is low risk", () => {
|
|
475
475
|
expect(trustSpec.baseRisk).toBe("low");
|
|
476
476
|
});
|
|
477
|
-
|
|
478
|
-
test("assistant trust remove is high risk", () => {
|
|
479
|
-
expect(trustSpec.subcommands!.remove.baseRisk).toBe("high");
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
test("assistant trust add is high risk", () => {
|
|
483
|
-
expect(trustSpec.subcommands!.add.baseRisk).toBe("high");
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
test("assistant trust update is high risk", () => {
|
|
487
|
-
expect(trustSpec.subcommands!.update.baseRisk).toBe("high");
|
|
488
|
-
});
|
|
489
477
|
});
|
|
490
478
|
|
|
491
479
|
// ── low-risk subcommands (no further subcommands) ────────────────────
|
|
@@ -545,9 +533,6 @@ describe("command-registry", () => {
|
|
|
545
533
|
assistantSpec.subcommands!.trust.subcommands!,
|
|
546
534
|
);
|
|
547
535
|
expect(trustSubs).toContain("list");
|
|
548
|
-
expect(trustSubs).toContain("add");
|
|
549
|
-
expect(trustSubs).toContain("update");
|
|
550
|
-
expect(trustSubs).toContain("remove");
|
|
551
536
|
});
|
|
552
537
|
|
|
553
538
|
test("covers expanded top-level assistant command groups", () => {
|
|
@@ -51,7 +51,7 @@ const BASH_TEST_CASES: Array<[string, RiskLevel]> = [
|
|
|
51
51
|
["command -v rm", RiskLevel.Low],
|
|
52
52
|
["command -V sudo", RiskLevel.Low],
|
|
53
53
|
["rm --help", RiskLevel.Low],
|
|
54
|
-
["mycustomtool --help", RiskLevel.
|
|
54
|
+
["mycustomtool --help", RiskLevel.Medium],
|
|
55
55
|
|
|
56
56
|
// Medium risk
|
|
57
57
|
["git push origin main", RiskLevel.Medium],
|
|
@@ -70,8 +70,6 @@ const BASH_TEST_CASES: Array<[string, RiskLevel]> = [
|
|
|
70
70
|
["chgrp group file.txt", RiskLevel.High],
|
|
71
71
|
['eval "ls"', RiskLevel.High],
|
|
72
72
|
['bash -c "echo hi"', RiskLevel.High],
|
|
73
|
-
["assistant trust add", RiskLevel.High],
|
|
74
|
-
["assistant trust update", RiskLevel.High],
|
|
75
73
|
["sudo rm -rf /", RiskLevel.High],
|
|
76
74
|
["rm -rf /tmp/stuff", RiskLevel.High],
|
|
77
75
|
["rm -r directory", RiskLevel.High],
|
package/src/runtime/client.ts
CHANGED
|
@@ -2,8 +2,13 @@ import {
|
|
|
2
2
|
buildUpstreamUrl,
|
|
3
3
|
createTimeoutController,
|
|
4
4
|
} from "@vellumai/assistant-client";
|
|
5
|
+
import {
|
|
6
|
+
normalizePublicBaseUrl,
|
|
7
|
+
TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER,
|
|
8
|
+
} from "@vellumai/service-contracts/twilio-ingress";
|
|
5
9
|
|
|
6
10
|
import type { ChannelId, InterfaceId } from "../channels/types.js";
|
|
11
|
+
import type { ConfigFileCache } from "../config-file-cache.js";
|
|
7
12
|
import {
|
|
8
13
|
mintIngressToken,
|
|
9
14
|
mintRelayToken,
|
|
@@ -494,14 +499,46 @@ export type TwilioForwardResponse = {
|
|
|
494
499
|
*/
|
|
495
500
|
const TWILIO_RELAY_TOKEN_PLACEHOLDER = "__VELLUM_RELAY_TOKEN__";
|
|
496
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Resolve the public base URL as a WebSocket URL (`wss://…`).
|
|
504
|
+
*
|
|
505
|
+
* Sources (in priority order):
|
|
506
|
+
* 1. `VELAY_BASE_URL` — present on platform-hosted gateway sidecars.
|
|
507
|
+
* When Velay is the tunnel provider, Twilio WebSocket connections
|
|
508
|
+
* are routed through the Velay service URL.
|
|
509
|
+
* 2. `ingress.publicBaseUrl` from the config file — written by Velay
|
|
510
|
+
* on tunnel registration, or set manually for self-hosted.
|
|
511
|
+
*
|
|
512
|
+
* Returns `undefined` when no source provides a value — the placeholder
|
|
513
|
+
* will remain in TwiML and Twilio will fail to connect, which is the
|
|
514
|
+
* correct behavior since without a public URL the assistant is unreachable.
|
|
515
|
+
*/
|
|
516
|
+
export function resolvePublicBaseWssUrl(
|
|
517
|
+
config: GatewayConfig,
|
|
518
|
+
configFile?: ConfigFileCache,
|
|
519
|
+
platformAssistantId?: string,
|
|
520
|
+
): string | undefined {
|
|
521
|
+
if (config.velayBaseUrl && platformAssistantId) {
|
|
522
|
+
const withPath =
|
|
523
|
+
config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
|
|
524
|
+
const normalized = normalizePublicBaseUrl(withPath);
|
|
525
|
+
if (normalized) return normalized.replace(/^http(s?)/, "ws$1");
|
|
526
|
+
}
|
|
527
|
+
const raw = configFile?.getString("ingress", "publicBaseUrl");
|
|
528
|
+
const normalized = normalizePublicBaseUrl(raw);
|
|
529
|
+
if (!normalized) return undefined;
|
|
530
|
+
return normalized.replace(/^http(s?)/, "ws$1");
|
|
531
|
+
}
|
|
532
|
+
|
|
497
533
|
/**
|
|
498
534
|
* Forward a validated Twilio voice webhook payload to the runtime.
|
|
499
535
|
* The gateway sends the parsed form params as JSON; the runtime's internal
|
|
500
536
|
* endpoint reconstructs what it needs.
|
|
501
537
|
*
|
|
502
|
-
* After receiving the TwiML response, the gateway
|
|
503
|
-
* placeholder with a freshly minted JWT
|
|
504
|
-
*
|
|
538
|
+
* After receiving the TwiML response, the gateway:
|
|
539
|
+
* 1. Replaces the relay token placeholder with a freshly minted JWT.
|
|
540
|
+
* 2. Replaces the public base URL placeholder with the real public
|
|
541
|
+
* WebSocket URL so the assistant never needs `ingress.publicBaseUrl`.
|
|
505
542
|
*
|
|
506
543
|
* For inbound calls, `assistantId` is resolved by the gateway from the "To"
|
|
507
544
|
* phone number and forwarded so the runtime knows which assistant to bootstrap.
|
|
@@ -510,6 +547,7 @@ export async function forwardTwilioVoiceWebhook(
|
|
|
510
547
|
config: GatewayConfig,
|
|
511
548
|
params: Record<string, string>,
|
|
512
549
|
originalUrl: string,
|
|
550
|
+
publicBaseWssUrl?: string,
|
|
513
551
|
): Promise<TwilioForwardResponse> {
|
|
514
552
|
cbBeforeRequest();
|
|
515
553
|
|
|
@@ -547,6 +585,23 @@ export async function forwardTwilioVoiceWebhook(
|
|
|
547
585
|
body = body.replaceAll(TWILIO_RELAY_TOKEN_PLACEHOLDER, mintRelayToken());
|
|
548
586
|
}
|
|
549
587
|
|
|
588
|
+
// Replace the public base URL placeholder with the real WebSocket URL.
|
|
589
|
+
// The assistant emits wss://__VELLUM_PUBLIC_BASE_URL__/… so it never
|
|
590
|
+
// needs ingress.publicBaseUrl — the gateway owns URL resolution.
|
|
591
|
+
if (body.includes(TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER)) {
|
|
592
|
+
if (publicBaseWssUrl) {
|
|
593
|
+
body = body.replaceAll(
|
|
594
|
+
TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER,
|
|
595
|
+
publicBaseWssUrl.replace(/\/+$/, ""),
|
|
596
|
+
);
|
|
597
|
+
} else {
|
|
598
|
+
log.error(
|
|
599
|
+
"TwiML contains public URL placeholder but no public base URL is configured. " +
|
|
600
|
+
"Twilio will fail to connect. Set VELAY_BASE_URL or ingress.publicBaseUrl.",
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
550
605
|
if (response.status >= 500) cbOnFailure();
|
|
551
606
|
else cbOnSuccess();
|
|
552
607
|
return { status: response.status, body, headers };
|