@vellumai/assistant 0.4.34 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +62 -43
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +100 -171
- package/src/memory/db-init.ts +1 -1
- package/src/memory/migrations/026-guardian-verification-sessions.ts +28 -9
- package/src/memory/migrations/027a-guardian-bootstrap-token.ts +16 -3
- package/src/memory/migrations/038-actor-token-records.ts +8 -1
- package/src/memory/migrations/039-actor-refresh-token-records.ts +11 -2
- package/src/memory/migrations/110-channel-guardian.ts +27 -6
- package/src/memory/migrations/112-assistant-inbox.ts +39 -15
- package/src/memory/migrations/114-notifications.ts +37 -15
- package/src/memory/migrations/117-conversation-attention.ts +33 -9
- package/src/memory/migrations/schema-introspection.ts +18 -0
- package/src/runtime/http-server.ts +3 -9
- package/src/runtime/http-types.ts +13 -1
- package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -1
- package/src/runtime/routes/surface-content-routes.ts +104 -0
package/package.json
CHANGED
|
@@ -481,8 +481,8 @@ describe("assistant ID boundary", () => {
|
|
|
481
481
|
const repoRoot = getRepoRoot();
|
|
482
482
|
|
|
483
483
|
// Scan all Drizzle schema files for assistantId column definitions.
|
|
484
|
-
//
|
|
485
|
-
//
|
|
484
|
+
// Match `assistantId:` followed by any Drizzle column builder (text(,
|
|
485
|
+
// integer(, blob(, real(, etc.) — not just text(.
|
|
486
486
|
const schemaGlobs = [
|
|
487
487
|
"assistant/src/memory/schema/*.ts",
|
|
488
488
|
"assistant/src/memory/schema/**/*.ts",
|
|
@@ -492,7 +492,7 @@ describe("assistant ID boundary", () => {
|
|
|
492
492
|
try {
|
|
493
493
|
grepOutput = execFileSync(
|
|
494
494
|
"git",
|
|
495
|
-
["grep", "-nE", "assistantId\\s
|
|
495
|
+
["grep", "-nE", "assistantId\\s*:", "--", ...schemaGlobs],
|
|
496
496
|
{ encoding: "utf-8", cwd: repoRoot },
|
|
497
497
|
).trim();
|
|
498
498
|
} catch (err) {
|
|
@@ -546,6 +546,9 @@ describe("assistant ID boundary", () => {
|
|
|
546
546
|
// Scan store files for exported function signatures that include
|
|
547
547
|
// assistantId as a parameter. This covers memory stores, contact stores,
|
|
548
548
|
// notification stores, credential/token stores, and call stores.
|
|
549
|
+
//
|
|
550
|
+
// We read each file and extract full parameter lists (which may span
|
|
551
|
+
// multiple lines) from exported functions to catch multiline signatures.
|
|
549
552
|
const storeGlobs = [
|
|
550
553
|
"assistant/src/memory/*.ts",
|
|
551
554
|
"assistant/src/contacts/*.ts",
|
|
@@ -556,52 +559,68 @@ describe("assistant ID boundary", () => {
|
|
|
556
559
|
"assistant/src/calls/call-store.ts",
|
|
557
560
|
];
|
|
558
561
|
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
grepOutput = execFileSync(
|
|
574
|
-
"git",
|
|
575
|
-
["grep", "-nE", pattern, "--", ...storeGlobs],
|
|
576
|
-
{ encoding: "utf-8", cwd: repoRoot },
|
|
577
|
-
).trim();
|
|
578
|
-
} catch (err) {
|
|
579
|
-
// Exit code 1 means no matches — happy path
|
|
580
|
-
if ((err as { status?: number }).status === 1) {
|
|
581
|
-
return;
|
|
562
|
+
// Find matching files using git ls-files with each glob
|
|
563
|
+
const matchedFiles: string[] = [];
|
|
564
|
+
for (const glob of storeGlobs) {
|
|
565
|
+
try {
|
|
566
|
+
const output = execFileSync("git", ["ls-files", "--", glob], {
|
|
567
|
+
encoding: "utf-8",
|
|
568
|
+
cwd: repoRoot,
|
|
569
|
+
}).trim();
|
|
570
|
+
if (output) {
|
|
571
|
+
matchedFiles.push(...output.split("\n").filter((f) => f.length > 0));
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
// Ignore errors — glob may not match anything
|
|
582
575
|
}
|
|
583
|
-
throw err;
|
|
584
576
|
}
|
|
585
577
|
|
|
586
|
-
const
|
|
587
|
-
const violations = allLines.filter((line) => {
|
|
588
|
-
const filePath = line.split(":")[0];
|
|
589
|
-
if (isTestFile(filePath)) return false;
|
|
590
|
-
if (isMigrationFile(filePath)) return false;
|
|
578
|
+
const violations: string[] = [];
|
|
591
579
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
580
|
+
// Regex to find the start of an exported function declaration or
|
|
581
|
+
// arrow-function expression. We capture everything from `export` up to
|
|
582
|
+
// and including the opening parenthesis of the parameter list.
|
|
583
|
+
const exportFnStartRegex =
|
|
584
|
+
/export\s+(?:async\s+)?function\s+\w+\s*\(|export\s+const\s+\w+\s*=\s*(?:async\s+)?\(/g;
|
|
585
|
+
|
|
586
|
+
for (const relPath of matchedFiles) {
|
|
587
|
+
if (isTestFile(relPath) || isMigrationFile(relPath)) continue;
|
|
588
|
+
|
|
589
|
+
const content = readFileSync(join(repoRoot, relPath), "utf-8");
|
|
590
|
+
|
|
591
|
+
exportFnStartRegex.lastIndex = 0;
|
|
592
|
+
for (
|
|
593
|
+
let match = exportFnStartRegex.exec(content);
|
|
594
|
+
match;
|
|
595
|
+
match = exportFnStartRegex.exec(content)
|
|
599
596
|
) {
|
|
600
|
-
|
|
601
|
-
|
|
597
|
+
// Find the matching closing paren to extract the full parameter list,
|
|
598
|
+
// which may span multiple lines.
|
|
599
|
+
const parenStart = match.index + match[0].length - 1; // index of '('
|
|
600
|
+
let depth = 1;
|
|
601
|
+
let paramEnd = parenStart + 1;
|
|
602
|
+
for (let i = parenStart + 1; i < content.length && depth > 0; i++) {
|
|
603
|
+
if (content[i] === "(") depth++;
|
|
604
|
+
if (content[i] === ")") depth--;
|
|
605
|
+
if (depth === 0) {
|
|
606
|
+
paramEnd = i;
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
602
610
|
|
|
603
|
-
|
|
604
|
-
|
|
611
|
+
const paramList = content.slice(parenStart + 1, paramEnd);
|
|
612
|
+
|
|
613
|
+
// Check if the parameter list contains assistantId as a word boundary
|
|
614
|
+
if (/\bassistantId\b/.test(paramList)) {
|
|
615
|
+
// Determine the line number of the export keyword for reporting
|
|
616
|
+
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
617
|
+
const firstLine = content
|
|
618
|
+
.slice(match.index, match.index + match[0].length)
|
|
619
|
+
.trim();
|
|
620
|
+
violations.push(`${relPath}:${lineNum}: ${firstLine}...`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
605
624
|
|
|
606
625
|
if (violations.length > 0) {
|
|
607
626
|
const message = [
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: "Google OAuth Setup"
|
|
3
|
-
description: "Set up Google Cloud OAuth credentials for Gmail and Calendar
|
|
3
|
+
description: "Set up Google Cloud OAuth credentials for Gmail and Calendar"
|
|
4
4
|
user-invocable: true
|
|
5
5
|
credential-setup-for: "gmail"
|
|
6
|
-
includes: ["
|
|
7
|
-
metadata: {"vellum": {"emoji": "\ud83d\udd11"}}
|
|
6
|
+
includes: ["public-ingress"]
|
|
7
|
+
metadata: { "vellum": { "emoji": "\ud83d\udd11" } }
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
You are helping your user set up Google Cloud OAuth credentials so Gmail and Google Calendar integrations can connect.
|
|
11
11
|
|
|
12
12
|
## Client Check
|
|
13
13
|
|
|
14
|
-
Determine
|
|
14
|
+
Determine which setup path to use based on the user's client:
|
|
15
15
|
|
|
16
|
-
- **macOS desktop app**: Follow
|
|
17
|
-
- **Telegram or other channel** (no browser automation): Follow
|
|
16
|
+
- **macOS desktop app**: Follow **Path B: CLI Setup** below.
|
|
17
|
+
- **Telegram or other channel** (no browser automation): Follow **Path A: Manual Setup for Channels** below.
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -29,6 +29,7 @@ Tell the user:
|
|
|
29
29
|
> **Setting up Gmail & Calendar from Telegram**
|
|
30
30
|
>
|
|
31
31
|
> Since I can't automate the browser from here, I'll walk you through each step with direct links. You'll need:
|
|
32
|
+
>
|
|
32
33
|
> 1. A Google account with access to Google Cloud Console
|
|
33
34
|
> 2. About 5 minutes
|
|
34
35
|
>
|
|
@@ -96,6 +97,7 @@ Tell the user:
|
|
|
96
97
|
### Channel Step 5: Create OAuth Credentials (Web Application)
|
|
97
98
|
|
|
98
99
|
Before sending Step 4 to the user, resolve the concrete callback URL:
|
|
100
|
+
|
|
99
101
|
- Read the configured public gateway URL (`ingress.publicBaseUrl`). If it is missing, run the `public-ingress` skill first.
|
|
100
102
|
- Build `oauthCallbackUrl` as `<public gateway URL>/webhooks/oauth/callback`.
|
|
101
103
|
- When you send the instructions below, replace `OAUTH_CALLBACK_URL` with that concrete value. Never send placeholders literally.
|
|
@@ -140,7 +142,7 @@ credential_store store:
|
|
|
140
142
|
|
|
141
143
|
**Step 6b: Client Secret (requires split entry to avoid security filters)**
|
|
142
144
|
|
|
143
|
-
The Client Secret starts with `GOCSPX-` which triggers the ingress secret scanner on channel messages. To work around this, ask the user to send only the portion
|
|
145
|
+
The Client Secret starts with `GOCSPX-` which triggers the ingress secret scanner on channel messages. To work around this, ask the user to send only the portion _after_ the prefix.
|
|
144
146
|
|
|
145
147
|
Tell the user:
|
|
146
148
|
|
|
@@ -192,229 +194,165 @@ After the user authorizes (they'll come back and say so, or you can suggest they
|
|
|
192
194
|
|
|
193
195
|
---
|
|
194
196
|
|
|
195
|
-
# Path B:
|
|
196
|
-
|
|
197
|
-
You will automate the entire GCP setup via the browser while the user watches in the Chrome window on the side. The user's only manual actions are: signing in to their Google account, and copy-pasting credentials from the Chrome window into secure prompts.
|
|
198
|
-
|
|
199
|
-
## Browser Interaction Principles
|
|
200
|
-
|
|
201
|
-
Google Cloud Console's UI changes frequently. Do NOT memorize or depend on specific element IDs, CSS selectors, or DOM structures. Instead:
|
|
202
|
-
|
|
203
|
-
1. **Snapshot first, act second.** Before every interaction, use `browser_snapshot` to discover interactive elements and their IDs. This is your primary navigation tool; it gives you the accessibility tree with clickable/typeable element IDs. Use `browser_screenshot` for visual context when the snapshot alone isn't enough.
|
|
204
|
-
2. **Adapt to what you see.** If an element's label or position differs from what you expect, use the snapshot to find the correct element. GCP may rename buttons, reorganize menus, or change form layouts at any time.
|
|
205
|
-
3. **Verify after every action.** After clicking, typing, or navigating, take a new snapshot to confirm the action succeeded. If it didn't, try an alternative interaction (e.g., if a dropdown didn't open on click, try pressing Space or Enter on the element).
|
|
206
|
-
4. **Never assume DOM structure.** Dropdowns may be `<select>`, `<mat-select>`, `<div role="listbox">`, or something else entirely. Use the snapshot to identify element types and interact accordingly.
|
|
207
|
-
5. **When stuck after 2 attempts, describe and ask.** Take a screenshot, describe what you see to the user, and ask for guidance.
|
|
208
|
-
|
|
209
|
-
## Anti-Loop Guardrails
|
|
197
|
+
# Path B: CLI Setup (macOS Desktop App)
|
|
210
198
|
|
|
211
|
-
|
|
199
|
+
**IMPORTANT: Always use `host_bash` (not `bash`) for all commands in this path.** The `gcloud` and `gws` CLIs need host access for Homebrew/npm installation, browser-based authentication, and interactive terminal prompts — none of which are available inside the sandbox.
|
|
212
200
|
|
|
213
|
-
|
|
214
|
-
2. **Fall back to manual.** Tell the user what you were trying to do and ask them to complete that step manually in the Chrome window (which they can see on the side). Give them the direct URL and clear text instructions.
|
|
215
|
-
3. **Resume automation** at the next step once the user confirms the manual step is done.
|
|
201
|
+
You will set up Google Cloud OAuth credentials using the `gcloud` and `gws` command-line tools. This avoids browser automation entirely — the user only needs to sign in once via the browser and copy-paste credentials from terminal output into secure prompts.
|
|
216
202
|
|
|
217
|
-
|
|
203
|
+
## CLI Step 1: Confirm
|
|
218
204
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
These actions are technically impossible in the browser automation environment. Attempting them wastes time and leads to loops:
|
|
222
|
-
|
|
223
|
-
- **Downloading files.** `browser_click` on a Download button does not save files to disk. There is NO JSON file to find at `~/Downloads` or anywhere else. Never click Download buttons.
|
|
224
|
-
- **Clipboard operations.** You cannot copy/paste via browser automation. The user must manually copy values from the Chrome window.
|
|
225
|
-
- **Deleting and recreating OAuth clients** to get a fresh secret. This orphans the stored client_id and causes `invalid_client` errors.
|
|
226
|
-
- **Navigating away from the credential dialog** before both credentials are stored. You will lose the Client Secret display and cannot get it back without creating a new client.
|
|
227
|
-
|
|
228
|
-
## Step 1: Single Upfront Confirmation
|
|
229
|
-
|
|
230
|
-
Use `ui_show` with `surface_type: "confirmation"`. Set `message` to just the title, and `detail` to the body:
|
|
205
|
+
Use `ui_show` with `surface_type: "confirmation"`:
|
|
231
206
|
|
|
232
207
|
- **message:** `Set up Google Cloud for Gmail & Calendar`
|
|
233
208
|
- **detail:**
|
|
234
209
|
> Here's what will happen:
|
|
235
|
-
>
|
|
236
|
-
>
|
|
237
|
-
>
|
|
238
|
-
>
|
|
210
|
+
>
|
|
211
|
+
> 1. **Install CLI tools** (`gcloud` and `gws`) if not already installed
|
|
212
|
+
> 2. **You sign in** to your Google account once via the browser
|
|
213
|
+
> 3. **CLI automates everything** — project creation, APIs, consent screen, and credentials
|
|
214
|
+
> 4. **You copy-paste credentials** from the terminal output into secure prompts
|
|
239
215
|
> 5. **You authorize Vellum** with one click
|
|
240
216
|
>
|
|
241
|
-
>
|
|
242
|
-
|
|
243
|
-
If the user declines, acknowledge and stop. No further confirmations are needed after this point.
|
|
244
|
-
|
|
245
|
-
## Step 2: Open Google Cloud Console and Sign In
|
|
217
|
+
> Takes about a minute after first-time setup. Ready?
|
|
246
218
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
Navigate to `https://console.cloud.google.com/`.
|
|
250
|
-
|
|
251
|
-
Take a screenshot to check the page state:
|
|
252
|
-
|
|
253
|
-
- **Sign-in page:** Tell the user: "Please sign in to your Google account in the Chrome window on the right side of your screen." Then auto-detect sign-in completion by polling with `browser_screenshot` every 5-10 seconds to check if the URL has moved away from `accounts.google.com` to `console.cloud.google.com`. Do NOT ask the user to "let me know when you're done"; detect it automatically. Once sign-in is detected, tell the user: "Signed in! Starting the automated setup now..."
|
|
254
|
-
- **Already signed in:** Tell the user: "Already signed in, starting setup now..." and continue immediately.
|
|
255
|
-
- **CAPTCHA:** The browser automation's built-in handoff will handle this. If it persists, tell the user: "There's a CAPTCHA in the browser, please complete it and I'll continue automatically."
|
|
256
|
-
|
|
257
|
-
**What you should see when done:** URL contains `console.cloud.google.com` and no sign-in overlay is visible.
|
|
258
|
-
|
|
259
|
-
## Step 3: Create or Select a Project
|
|
260
|
-
|
|
261
|
-
**Goal:** A GCP project named "Vellum Assistant" exists and is selected.
|
|
262
|
-
|
|
263
|
-
Tell the user: "Creating Google Cloud project..."
|
|
264
|
-
|
|
265
|
-
Navigate to `https://console.cloud.google.com/projectcreate`.
|
|
266
|
-
|
|
267
|
-
Take a `browser_snapshot`. Find the project name input field (look for an element with label containing "Project name" or a text input near the top of the form). Type "Vellum Assistant" into it.
|
|
268
|
-
|
|
269
|
-
Look for a "Create" button in the snapshot and click it. Wait 10-15 seconds for project creation, then take a screenshot to check for:
|
|
270
|
-
- **Success message** or redirect to the new project dashboard. Note the project ID from the URL or page content.
|
|
271
|
-
- **"Project name already in use" error**: that's fine. Navigate to `https://console.cloud.google.com/cloud-resource-manager` to find and select the existing "Vellum Assistant" project. Use `browser_extract` to read the project ID from the page.
|
|
272
|
-
- **Organization restriction or quota error**: tell the user what happened and ask them to resolve it.
|
|
273
|
-
|
|
274
|
-
**What you should see when done:** The project selector in the top bar shows the project name, and you have the project ID (something like `vellum-assistant-12345`).
|
|
219
|
+
If the user declines, acknowledge and stop.
|
|
275
220
|
|
|
276
|
-
|
|
221
|
+
## CLI Step 2: Install Prerequisites
|
|
277
222
|
|
|
278
|
-
|
|
223
|
+
Check for and install each prerequisite. If any installation fails (e.g., Homebrew not available, corporate restrictions), tell the user what went wrong and provide manual installation instructions.
|
|
279
224
|
|
|
280
|
-
|
|
225
|
+
### gcloud
|
|
281
226
|
|
|
282
|
-
|
|
227
|
+
```bash
|
|
228
|
+
which gcloud
|
|
229
|
+
```
|
|
283
230
|
|
|
284
|
-
|
|
285
|
-
1. `https://console.cloud.google.com/apis/library/gmail.googleapis.com?project=PROJECT_ID`
|
|
286
|
-
2. `https://console.cloud.google.com/apis/library/calendar-json.googleapis.com?project=PROJECT_ID`
|
|
231
|
+
If missing:
|
|
287
232
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
233
|
+
```bash
|
|
234
|
+
brew install google-cloud-sdk
|
|
235
|
+
```
|
|
291
236
|
|
|
292
|
-
|
|
237
|
+
After installation, verify it works:
|
|
293
238
|
|
|
294
|
-
|
|
239
|
+
```bash
|
|
240
|
+
gcloud --version
|
|
241
|
+
```
|
|
295
242
|
|
|
296
|
-
|
|
243
|
+
### gws
|
|
297
244
|
|
|
298
|
-
|
|
245
|
+
```bash
|
|
246
|
+
which gws
|
|
247
|
+
```
|
|
299
248
|
|
|
300
|
-
|
|
249
|
+
If missing:
|
|
301
250
|
|
|
302
|
-
|
|
251
|
+
```bash
|
|
252
|
+
npm install -g @googleworkspace/cli
|
|
253
|
+
```
|
|
303
254
|
|
|
304
|
-
|
|
255
|
+
After installation, verify it works:
|
|
305
256
|
|
|
306
|
-
|
|
257
|
+
```bash
|
|
258
|
+
gws --version
|
|
259
|
+
```
|
|
307
260
|
|
|
308
|
-
|
|
261
|
+
## CLI Step 3: Sign In to Google
|
|
309
262
|
|
|
310
|
-
|
|
263
|
+
Tell the user: "Opening your browser so you can sign in to Google..."
|
|
311
264
|
|
|
312
|
-
|
|
265
|
+
```bash
|
|
266
|
+
gcloud auth login
|
|
267
|
+
```
|
|
313
268
|
|
|
314
|
-
|
|
269
|
+
This opens the browser for Google sign-in. Wait for the command to complete — it prints the authenticated account email on success.
|
|
315
270
|
|
|
316
|
-
|
|
271
|
+
If the user is already authenticated (`gcloud auth list` shows an active account), skip this step and tell the user: "Already signed in, continuing setup..."
|
|
317
272
|
|
|
318
|
-
|
|
319
|
-
- **App name**: Type "Vellum Assistant" in the app name field.
|
|
320
|
-
- **User support email**: This is typically a dropdown showing the signed-in user's email. Use `browser_snapshot` to find a `<select>` or clickable dropdown element near "User support email". Select the user's email.
|
|
321
|
-
- **Developer contact email**: Type the user's email into this field. (Use the same email visible in the support email dropdown if you can read it, or use `browser_extract` to find the email shown on the page.)
|
|
322
|
-
- Click **Save and Continue** if on a multi-page wizard.
|
|
273
|
+
## CLI Step 4: GCP Project Setup
|
|
323
274
|
|
|
324
|
-
|
|
325
|
-
- Click **"Add or Remove Scopes"** (or similar button).
|
|
326
|
-
- In the scope picker dialog, look for a text input labeled **"Manually add scopes"** or **"Filter"** at the bottom or top of the dialog.
|
|
327
|
-
- Paste all 6 scopes at once as a comma-separated string into that input:
|
|
328
|
-
```
|
|
329
|
-
https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.modify,https://www.googleapis.com/auth/gmail.send,https://www.googleapis.com/auth/calendar.readonly,https://www.googleapis.com/auth/calendar.events,https://www.googleapis.com/auth/userinfo.email
|
|
330
|
-
```
|
|
331
|
-
- Click **"Add to Table"** or **"Update"** to confirm the scopes.
|
|
332
|
-
- If no manual input is available, you'll need to search for and check each scope individually using the scope tree. Search for each scope URL in the filter box and check its checkbox.
|
|
333
|
-
- Click **Save and Continue** (or **Update** then **Save and Continue**).
|
|
275
|
+
Tell the user: "Setting up your Google Cloud project, APIs, and credentials..."
|
|
334
276
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
- Click **Add** then **Save and Continue**.
|
|
277
|
+
```bash
|
|
278
|
+
gws auth setup
|
|
279
|
+
```
|
|
339
280
|
|
|
340
|
-
|
|
341
|
-
- Click **"Back to Dashboard"** or **"Submit"**.
|
|
281
|
+
This command automates:
|
|
342
282
|
|
|
343
|
-
|
|
283
|
+
- GCP project creation (or selection of an existing one)
|
|
284
|
+
- OAuth consent screen configuration
|
|
285
|
+
- OAuth credential creation
|
|
344
286
|
|
|
345
|
-
|
|
287
|
+
Wait for the command to complete. It may have interactive prompts — let them run in the terminal and the user can respond if needed.
|
|
346
288
|
|
|
347
|
-
|
|
289
|
+
Note the **project ID** from the output — you'll need it for the next step.
|
|
348
290
|
|
|
349
|
-
|
|
291
|
+
## CLI Step 5: Enable Additional APIs
|
|
350
292
|
|
|
351
|
-
|
|
293
|
+
`gws auth setup` enables the APIs it needs, but Vellum also requires the Calendar and People APIs. Enable them explicitly using the project ID from step 4:
|
|
352
294
|
|
|
353
|
-
|
|
295
|
+
```bash
|
|
296
|
+
gcloud services enable calendar-json.googleapis.com --project=PROJECT_ID
|
|
297
|
+
gcloud services enable people.googleapis.com --project=PROJECT_ID
|
|
298
|
+
```
|
|
354
299
|
|
|
355
|
-
|
|
300
|
+
If either command reports the API is already enabled, that's fine — continue.
|
|
356
301
|
|
|
357
|
-
|
|
302
|
+
## CLI Step 5b: Update OAuth Consent Screen Scopes
|
|
358
303
|
|
|
359
|
-
|
|
360
|
-
- **Application type**: Find the dropdown and select **"Desktop app"**. This may be a `<select>` element or a custom dropdown. Use the snapshot to identify it. You might need to click the dropdown first, then take another snapshot to see the options, then click "Desktop app".
|
|
361
|
-
- **Name**: Type "Vellum Assistant" in the name field.
|
|
362
|
-
- Do NOT add any redirect URIs. The desktop app flow doesn't need them.
|
|
304
|
+
`gws auth setup` configures the consent screen with Gmail scopes only. Vellum also needs Calendar, Contacts, and userinfo scopes. Tell the user to add the missing scopes:
|
|
363
305
|
|
|
364
|
-
|
|
306
|
+
> **Update consent screen scopes**
|
|
307
|
+
>
|
|
308
|
+
> Open: `https://console.cloud.google.com/apis/credentials/consent/edit?project=PROJECT_ID`
|
|
309
|
+
>
|
|
310
|
+
> 1. Click through to the **Scopes** page (click **Save and Continue** on the first page)
|
|
311
|
+
> 2. Click **Add or Remove Scopes** and ensure all of these are present:
|
|
312
|
+
> - `https://www.googleapis.com/auth/gmail.readonly`
|
|
313
|
+
> - `https://www.googleapis.com/auth/gmail.modify`
|
|
314
|
+
> - `https://www.googleapis.com/auth/gmail.send`
|
|
315
|
+
> - `https://www.googleapis.com/auth/calendar.readonly`
|
|
316
|
+
> - `https://www.googleapis.com/auth/calendar.events`
|
|
317
|
+
> - `https://www.googleapis.com/auth/userinfo.email`
|
|
318
|
+
> - `https://www.googleapis.com/auth/contacts.readonly`
|
|
319
|
+
> 3. Click **Update**, then **Save and Continue** through the remaining pages
|
|
365
320
|
|
|
366
|
-
|
|
321
|
+
(Substitute the actual project ID into the URL.)
|
|
367
322
|
|
|
368
|
-
|
|
323
|
+
The Gmail scopes may already be present from `gws auth setup` — add any that are missing.
|
|
369
324
|
|
|
370
|
-
|
|
325
|
+
## CLI Step 6: Collect Credentials
|
|
371
326
|
|
|
372
|
-
|
|
373
|
-
credential_store store:
|
|
374
|
-
service: "integration:gmail"
|
|
375
|
-
field: "client_id"
|
|
376
|
-
value: "<the Client ID extracted from the page>"
|
|
377
|
-
```
|
|
327
|
+
The `gws auth setup` output or the GCP Console shows the Client ID and Client Secret. Ask the user to copy-paste them into secure prompts.
|
|
378
328
|
|
|
379
|
-
|
|
329
|
+
**Client ID:**
|
|
380
330
|
|
|
381
331
|
```
|
|
382
332
|
credential_store prompt:
|
|
383
333
|
service: "integration:gmail"
|
|
384
334
|
field: "client_id"
|
|
385
335
|
label: "Google OAuth Client ID"
|
|
386
|
-
description: "Copy the Client ID from the
|
|
336
|
+
description: "Copy the Client ID from the setup output or GCP Console. It looks like 123456789-xxxxx.apps.googleusercontent.com"
|
|
387
337
|
placeholder: "xxxxx.apps.googleusercontent.com"
|
|
388
338
|
```
|
|
389
339
|
|
|
390
|
-
**
|
|
391
|
-
|
|
392
|
-
> "Got the Client ID! Now I need the Client Secret. You can see it in the dialog in the Chrome window. It starts with `GOCSPX-`. Please copy it and paste it into the secure prompt below."
|
|
393
|
-
|
|
394
|
-
And present the secure prompt:
|
|
340
|
+
**Client Secret:**
|
|
395
341
|
|
|
396
342
|
```
|
|
397
343
|
credential_store prompt:
|
|
398
344
|
service: "integration:gmail"
|
|
399
345
|
field: "client_secret"
|
|
400
346
|
label: "Google OAuth Client Secret"
|
|
401
|
-
description: "Copy the Client Secret from the
|
|
347
|
+
description: "Copy the Client Secret from the setup output or GCP Console. It starts with GOCSPX-"
|
|
402
348
|
placeholder: "GOCSPX-..."
|
|
403
349
|
```
|
|
404
350
|
|
|
405
|
-
Wait for
|
|
406
|
-
|
|
407
|
-
If the user has trouble locating the secret, take a `browser_screenshot` and describe where the secret field is on the screen, but do NOT attempt to read the secret value yourself. It must come from the user for accuracy.
|
|
408
|
-
|
|
409
|
-
**What you should see when done:** `credential_store list` shows both `client_id` and `client_secret` for `integration:gmail`.
|
|
410
|
-
|
|
411
|
-
Tell the user: "Credentials stored securely!"
|
|
351
|
+
Wait for both prompts to be completed before continuing.
|
|
412
352
|
|
|
413
|
-
## Step 7:
|
|
353
|
+
## CLI Step 7: Authorize
|
|
414
354
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
Tell the user: "Starting the authorization flow — a Google sign-in page will open in a few seconds. Just click 'Allow' when it appears."
|
|
355
|
+
Tell the user: "Starting the authorization flow — a Google sign-in page will open. Just click 'Allow' when it appears."
|
|
418
356
|
|
|
419
357
|
Use `credential_store` with:
|
|
420
358
|
|
|
@@ -429,15 +367,6 @@ This auto-reads client_id and client_secret from the secure store and auto-fills
|
|
|
429
367
|
|
|
430
368
|
**Verify:** The `oauth2_connect` call returns a success message with the connected account email.
|
|
431
369
|
|
|
432
|
-
## Step 8: Done!
|
|
370
|
+
## CLI Step 8: Done!
|
|
433
371
|
|
|
434
372
|
Tell the user: "**Gmail and Calendar are connected!** You can now read, search, and send emails, plus view and manage your calendar. Try asking me to check your inbox or show your upcoming events!"
|
|
435
|
-
|
|
436
|
-
## Error Handling
|
|
437
|
-
|
|
438
|
-
- **Page load failures:** Retry navigation once. If it still fails, tell the user and ask them to check their internet connection.
|
|
439
|
-
- **Permission errors in GCP:** The user may need billing enabled or organization-level permissions. Explain clearly and ask them to resolve it.
|
|
440
|
-
- **Consent screen already configured:** Don't overwrite. Skip to credential creation.
|
|
441
|
-
- **Element not found:** Take a fresh `browser_snapshot` to re-assess. The GCP UI may have changed. Describe what you see and try alternative approaches. If stuck after 2 attempts, ask the user for guidance. They can see the Chrome window too.
|
|
442
|
-
- **OAuth flow timeout or failure:** Offer to retry. The credentials are already stored, so reconnecting only requires re-running the authorization flow.
|
|
443
|
-
- **Any unexpected state:** Take a `browser_screenshot`, describe what you see, and ask the user for guidance.
|
package/src/memory/db-init.ts
CHANGED
|
@@ -36,7 +36,6 @@ import {
|
|
|
36
36
|
migrateBackfillContactInteractionStats,
|
|
37
37
|
migrateBackfillGuardianPrincipalId,
|
|
38
38
|
migrateCallSessionMode,
|
|
39
|
-
migrateDropAssistantIdColumns,
|
|
40
39
|
migrateCanonicalGuardianDeliveriesDestinationIndex,
|
|
41
40
|
migrateCanonicalGuardianRequesterChatId,
|
|
42
41
|
migrateChannelInboundDeliveredSegments,
|
|
@@ -46,6 +45,7 @@ import {
|
|
|
46
45
|
migrateContactsNotesColumn,
|
|
47
46
|
migrateContactsRolePrincipal,
|
|
48
47
|
migrateConversationsThreadTypeIndex,
|
|
48
|
+
migrateDropAssistantIdColumns,
|
|
49
49
|
migrateDropLegacyMemberGuardianTables,
|
|
50
50
|
migrateFkCascadeRebuilds,
|
|
51
51
|
migrateGuardianActionFollowup,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Extend channel_guardian_verification_challenges with outbound verification
|
|
@@ -83,13 +84,31 @@ export function migrateGuardianVerificationSessions(database: DrizzleDb): void {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// -- Indexes for session lookups --
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
if (
|
|
88
|
+
tableHasColumn(
|
|
89
|
+
database,
|
|
90
|
+
"channel_guardian_verification_challenges",
|
|
91
|
+
"assistant_id",
|
|
92
|
+
)
|
|
93
|
+
) {
|
|
94
|
+
database.run(
|
|
95
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_active ON channel_guardian_verification_challenges(assistant_id, channel, status)`,
|
|
96
|
+
);
|
|
97
|
+
database.run(
|
|
98
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_identity ON channel_guardian_verification_challenges(assistant_id, channel, expected_external_user_id, expected_chat_id, status)`,
|
|
99
|
+
);
|
|
100
|
+
database.run(
|
|
101
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_destination ON channel_guardian_verification_challenges(assistant_id, channel, destination_address)`,
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
database.run(
|
|
105
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_active ON channel_guardian_verification_challenges(channel, status)`,
|
|
106
|
+
);
|
|
107
|
+
database.run(
|
|
108
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_identity ON channel_guardian_verification_challenges(channel, expected_external_user_id, expected_chat_id, status)`,
|
|
109
|
+
);
|
|
110
|
+
database.run(
|
|
111
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_destination ON channel_guardian_verification_challenges(channel, destination_address)`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
95
114
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Add bootstrap_token_hash column to channel_guardian_verification_challenges.
|
|
@@ -16,7 +17,19 @@ export function migrateGuardianBootstrapToken(database: DrizzleDb): void {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
// Index for looking up pending_bootstrap sessions by bootstrap token hash
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
if (
|
|
21
|
+
tableHasColumn(
|
|
22
|
+
database,
|
|
23
|
+
"channel_guardian_verification_challenges",
|
|
24
|
+
"assistant_id",
|
|
25
|
+
)
|
|
26
|
+
) {
|
|
27
|
+
database.run(
|
|
28
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_bootstrap ON channel_guardian_verification_challenges(assistant_id, channel, bootstrap_token_hash, status)`,
|
|
29
|
+
);
|
|
30
|
+
} else {
|
|
31
|
+
database.run(
|
|
32
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_bootstrap ON channel_guardian_verification_challenges(channel, bootstrap_token_hash, status)`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
22
35
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Create the actor_token_records table for hash-only actor token persistence.
|
|
@@ -24,9 +25,15 @@ export function createActorTokenRecordsTable(database: DrizzleDb): void {
|
|
|
24
25
|
`);
|
|
25
26
|
|
|
26
27
|
// Unique active token per device binding
|
|
27
|
-
database
|
|
28
|
+
if (tableHasColumn(database, "actor_token_records", "assistant_id")) {
|
|
29
|
+
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_actor_tokens_active_device
|
|
28
30
|
ON actor_token_records(assistant_id, guardian_principal_id, hashed_device_id)
|
|
29
31
|
WHERE status = 'active'`);
|
|
32
|
+
} else {
|
|
33
|
+
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_actor_tokens_active_device
|
|
34
|
+
ON actor_token_records(guardian_principal_id, hashed_device_id)
|
|
35
|
+
WHERE status = 'active'`);
|
|
36
|
+
}
|
|
30
37
|
|
|
31
38
|
// Token hash lookup for verification
|
|
32
39
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_actor_tokens_hash
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Create the actor_refresh_token_records table for hash-only refresh token persistence.
|
|
@@ -34,10 +35,18 @@ export function createActorRefreshTokenRecordsTable(database: DrizzleDb): void {
|
|
|
34
35
|
// Unique active refresh token per device binding.
|
|
35
36
|
// DROP first so that databases that already created the older non-unique
|
|
36
37
|
// index with the same name get upgraded to UNIQUE.
|
|
37
|
-
database
|
|
38
|
-
|
|
38
|
+
if (tableHasColumn(database, "actor_refresh_token_records", "assistant_id")) {
|
|
39
|
+
database.run(
|
|
40
|
+
/*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`,
|
|
41
|
+
);
|
|
42
|
+
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
|
|
39
43
|
ON actor_refresh_token_records(assistant_id, guardian_principal_id, hashed_device_id)
|
|
40
44
|
WHERE status = 'active'`);
|
|
45
|
+
} else {
|
|
46
|
+
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
|
|
47
|
+
ON actor_refresh_token_records(guardian_principal_id, hashed_device_id)
|
|
48
|
+
WHERE status = 'active'`);
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
// Family lookup for replay detection (revoke entire family)
|
|
43
52
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Channel guardian tables: bindings, verification challenges, approval requests,
|
|
@@ -43,9 +44,21 @@ export function createChannelGuardianTables(database: DrizzleDb): void {
|
|
|
43
44
|
)
|
|
44
45
|
`);
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
if (
|
|
48
|
+
tableHasColumn(
|
|
49
|
+
database,
|
|
50
|
+
"channel_guardian_verification_challenges",
|
|
51
|
+
"assistant_id",
|
|
52
|
+
)
|
|
53
|
+
) {
|
|
54
|
+
database.run(
|
|
55
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(assistant_id, channel, challenge_hash, status)`,
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
database.run(
|
|
59
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(channel, challenge_hash, status)`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
49
62
|
|
|
50
63
|
database.run(/*sql*/ `
|
|
51
64
|
CREATE TABLE IF NOT EXISTS channel_guardian_approval_requests (
|
|
@@ -139,7 +152,15 @@ export function createChannelGuardianTables(database: DrizzleDb): void {
|
|
|
139
152
|
/* already exists */
|
|
140
153
|
}
|
|
141
154
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
155
|
+
if (
|
|
156
|
+
tableHasColumn(database, "channel_guardian_rate_limits", "assistant_id")
|
|
157
|
+
) {
|
|
158
|
+
database.run(
|
|
159
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(assistant_id, channel, actor_external_user_id, actor_chat_id)`,
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
database.run(
|
|
163
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(channel, actor_external_user_id, actor_chat_id)`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
145
166
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DrizzleDb } from "../db-connection.js";
|
|
2
2
|
import { migrateBackfillInboxThreadStateFromBindings } from "./014-backfill-inbox-thread-state.js";
|
|
3
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Assistant inbox tables: ingress invites, ingress members, inbox thread state.
|
|
@@ -28,12 +29,21 @@ export function createAssistantInboxTables(database: DrizzleDb): void {
|
|
|
28
29
|
database.run(
|
|
29
30
|
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ingress_invites_token_hash ON assistant_ingress_invites(token_hash)`,
|
|
30
31
|
);
|
|
31
|
-
database
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
if (tableHasColumn(database, "assistant_ingress_invites", "assistant_id")) {
|
|
33
|
+
database.run(
|
|
34
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(assistant_id, source_channel, status, expires_at)`,
|
|
35
|
+
);
|
|
36
|
+
database.run(
|
|
37
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(assistant_id, source_channel, created_at)`,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
database.run(
|
|
41
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(source_channel, status, expires_at)`,
|
|
42
|
+
);
|
|
43
|
+
database.run(
|
|
44
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(source_channel, created_at)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
37
47
|
|
|
38
48
|
database.run(/*sql*/ `
|
|
39
49
|
CREATE TABLE IF NOT EXISTS assistant_ingress_members (
|
|
@@ -90,15 +100,29 @@ export function createAssistantInboxTables(database: DrizzleDb): void {
|
|
|
90
100
|
)
|
|
91
101
|
`);
|
|
92
102
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
if (
|
|
104
|
+
tableHasColumn(database, "assistant_inbox_thread_state", "assistant_id")
|
|
105
|
+
) {
|
|
106
|
+
database.run(
|
|
107
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(assistant_id, source_channel, external_chat_id)`,
|
|
108
|
+
);
|
|
109
|
+
database.run(
|
|
110
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(assistant_id, last_message_at)`,
|
|
111
|
+
);
|
|
112
|
+
database.run(
|
|
113
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(assistant_id, has_pending_escalation, last_message_at)`,
|
|
114
|
+
);
|
|
115
|
+
} else {
|
|
116
|
+
database.run(
|
|
117
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(source_channel, external_chat_id)`,
|
|
118
|
+
);
|
|
119
|
+
database.run(
|
|
120
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(last_message_at)`,
|
|
121
|
+
);
|
|
122
|
+
database.run(
|
|
123
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(has_pending_escalation, last_message_at)`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
102
126
|
|
|
103
127
|
migrateBackfillInboxThreadStateFromBindings(database);
|
|
104
128
|
}
|
|
@@ -2,6 +2,7 @@ import type { DrizzleDb } from "../db-connection.js";
|
|
|
2
2
|
import { migrateNotificationTablesSchema } from "./019-notification-tables-schema-migration.js";
|
|
3
3
|
import { migrateNotificationDeliveryPairingColumns } from "./027-notification-delivery-pairing-columns.js";
|
|
4
4
|
import { migrateNotificationDeliveryClientAck } from "./028-notification-delivery-client-ack.js";
|
|
5
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Notification system tables: preferences, events, decisions, and deliveries.
|
|
@@ -24,12 +25,18 @@ export function createNotificationTables(database: DrizzleDb): void {
|
|
|
24
25
|
)
|
|
25
26
|
`);
|
|
26
27
|
|
|
27
|
-
database
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
if (tableHasColumn(database, "notification_preferences", "assistant_id")) {
|
|
29
|
+
database.run(
|
|
30
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_id ON notification_preferences(assistant_id)`,
|
|
31
|
+
);
|
|
32
|
+
database.run(
|
|
33
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_priority ON notification_preferences(assistant_id, priority DESC)`,
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
database.run(
|
|
37
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_priority ON notification_preferences(priority DESC)`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
33
40
|
|
|
34
41
|
database.run(/*sql*/ `
|
|
35
42
|
CREATE TABLE IF NOT EXISTS notification_events (
|
|
@@ -46,12 +53,21 @@ export function createNotificationTables(database: DrizzleDb): void {
|
|
|
46
53
|
)
|
|
47
54
|
`);
|
|
48
55
|
|
|
49
|
-
database
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
if (tableHasColumn(database, "notification_events", "assistant_id")) {
|
|
57
|
+
database.run(
|
|
58
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_assistant_event_created ON notification_events(assistant_id, source_event_name, created_at)`,
|
|
59
|
+
);
|
|
60
|
+
database.run(
|
|
61
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(assistant_id, dedupe_key) WHERE dedupe_key IS NOT NULL`,
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
database.run(
|
|
65
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_event_created ON notification_events(source_event_name, created_at)`,
|
|
66
|
+
);
|
|
67
|
+
database.run(
|
|
68
|
+
/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(dedupe_key) WHERE dedupe_key IS NOT NULL`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
55
71
|
|
|
56
72
|
database.run(/*sql*/ `
|
|
57
73
|
CREATE TABLE IF NOT EXISTS notification_decisions (
|
|
@@ -97,9 +113,15 @@ export function createNotificationTables(database: DrizzleDb): void {
|
|
|
97
113
|
database.run(
|
|
98
114
|
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_decision_id ON notification_deliveries(notification_decision_id)`,
|
|
99
115
|
);
|
|
100
|
-
database
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
if (tableHasColumn(database, "notification_deliveries", "assistant_id")) {
|
|
117
|
+
database.run(
|
|
118
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_assistant_status ON notification_deliveries(assistant_id, status)`,
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
database.run(
|
|
122
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries(status)`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
103
125
|
|
|
104
126
|
// Add conversation pairing audit columns (idempotent ALTER TABLE)
|
|
105
127
|
migrateNotificationDeliveryPairingColumns(database);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { tableHasColumn } from "./schema-introspection.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Conversation attention tables: append-only evidence log and single-row
|
|
@@ -24,9 +25,17 @@ export function createConversationAttentionTables(database: DrizzleDb): void {
|
|
|
24
25
|
database.run(
|
|
25
26
|
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_conv_observed ON conversation_attention_events(conversation_id, observed_at DESC)`,
|
|
26
27
|
);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
28
|
+
if (
|
|
29
|
+
tableHasColumn(database, "conversation_attention_events", "assistant_id")
|
|
30
|
+
) {
|
|
31
|
+
database.run(
|
|
32
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_assistant_observed ON conversation_attention_events(assistant_id, observed_at DESC)`,
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
database.run(
|
|
36
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_observed ON conversation_attention_events(observed_at)`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
30
39
|
database.run(
|
|
31
40
|
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_channel_observed ON conversation_attention_events(source_channel, observed_at DESC)`,
|
|
32
41
|
);
|
|
@@ -50,10 +59,25 @@ export function createConversationAttentionTables(database: DrizzleDb): void {
|
|
|
50
59
|
)
|
|
51
60
|
`);
|
|
52
61
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
if (
|
|
63
|
+
tableHasColumn(
|
|
64
|
+
database,
|
|
65
|
+
"conversation_assistant_attention_state",
|
|
66
|
+
"assistant_id",
|
|
67
|
+
)
|
|
68
|
+
) {
|
|
69
|
+
database.run(
|
|
70
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_latest_msg ON conversation_assistant_attention_state(assistant_id, latest_assistant_message_at DESC)`,
|
|
71
|
+
);
|
|
72
|
+
database.run(
|
|
73
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_last_seen ON conversation_assistant_attention_state(assistant_id, last_seen_assistant_message_at DESC)`,
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
database.run(
|
|
77
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_latest_msg ON conversation_assistant_attention_state(latest_assistant_message_at)`,
|
|
78
|
+
);
|
|
79
|
+
database.run(
|
|
80
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_last_seen ON conversation_assistant_attention_state(last_seen_assistant_message_at)`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
59
83
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Startup still replays the historical table/index bootstrap helpers on every
|
|
5
|
+
* process launch, so migrations need a cheap way to branch on the live schema.
|
|
6
|
+
*/
|
|
7
|
+
export function tableHasColumn(
|
|
8
|
+
database: DrizzleDb,
|
|
9
|
+
tableName: string,
|
|
10
|
+
columnName: string,
|
|
11
|
+
): boolean {
|
|
12
|
+
const raw = getSqliteFrom(database);
|
|
13
|
+
const columns = raw.query(`PRAGMA table_info(${tableName})`).all() as Array<{
|
|
14
|
+
name: string;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
return columns.some((column) => column.name === columnName);
|
|
18
|
+
}
|
|
@@ -127,6 +127,7 @@ import {
|
|
|
127
127
|
} from "./routes/pairing-routes.js";
|
|
128
128
|
import { secretRouteDefinitions } from "./routes/secret-routes.js";
|
|
129
129
|
import { surfaceActionRouteDefinitions } from "./routes/surface-action-routes.js";
|
|
130
|
+
import { surfaceContentRouteDefinitions } from "./routes/surface-content-routes.js";
|
|
130
131
|
import { trustRulesRouteDefinitions } from "./routes/trust-rules-routes.js";
|
|
131
132
|
import { twilioRouteDefinitions } from "./routes/twilio-routes.js";
|
|
132
133
|
|
|
@@ -183,15 +184,7 @@ export class RuntimeHttpServer {
|
|
|
183
184
|
private pairingStore = new PairingStore();
|
|
184
185
|
private pairingBroadcast?: (msg: ServerMessage) => void;
|
|
185
186
|
private sendMessageDeps?: SendMessageDeps;
|
|
186
|
-
private findSession?:
|
|
187
|
-
| {
|
|
188
|
-
handleSurfaceAction(
|
|
189
|
-
surfaceId: string,
|
|
190
|
-
actionId: string,
|
|
191
|
-
data?: Record<string, unknown>,
|
|
192
|
-
): void;
|
|
193
|
-
}
|
|
194
|
-
| undefined;
|
|
187
|
+
private findSession?: RuntimeHttpServerOptions["findSession"];
|
|
195
188
|
private router: HttpRouter;
|
|
196
189
|
|
|
197
190
|
constructor(options: RuntimeHttpServerOptions = {}) {
|
|
@@ -847,6 +840,7 @@ export class RuntimeHttpServer {
|
|
|
847
840
|
...approvalRouteDefinitions(),
|
|
848
841
|
...trustRulesRouteDefinitions(),
|
|
849
842
|
...surfaceActionRouteDefinitions({ findSession: this.findSession }),
|
|
843
|
+
...surfaceContentRouteDefinitions({ findSession: this.findSession }),
|
|
850
844
|
...guardianActionRouteDefinitions(),
|
|
851
845
|
|
|
852
846
|
...contactRouteDefinitions(),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Shared types for the runtime HTTP server and its route handlers.
|
|
3
3
|
*/
|
|
4
4
|
import type { ChannelId, InterfaceId } from "../channels/types.js";
|
|
5
|
+
import type { SurfaceData, SurfaceType } from "../daemon/ipc-contract/surfaces.js";
|
|
5
6
|
import type { Session } from "../daemon/session.js";
|
|
6
7
|
import type { TrustContext } from "../daemon/session-runtime-assembly.js";
|
|
7
8
|
import type {
|
|
@@ -169,7 +170,7 @@ export interface RuntimeHttpServerOptions {
|
|
|
169
170
|
guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator;
|
|
170
171
|
/** Dependencies for the POST /v1/messages queue-if-busy handler. */
|
|
171
172
|
sendMessageDeps?: SendMessageDeps;
|
|
172
|
-
/** Lookup an active session by ID (for surface actions
|
|
173
|
+
/** Lookup an active session by ID (for surface actions and content fetches). */
|
|
173
174
|
findSession?: (sessionId: string) =>
|
|
174
175
|
| {
|
|
175
176
|
handleSurfaceAction(
|
|
@@ -177,6 +178,17 @@ export interface RuntimeHttpServerOptions {
|
|
|
177
178
|
actionId: string,
|
|
178
179
|
data?: Record<string, unknown>,
|
|
179
180
|
): void;
|
|
181
|
+
surfaceState: Map<
|
|
182
|
+
string,
|
|
183
|
+
{ surfaceType: SurfaceType; data: SurfaceData; title?: string }
|
|
184
|
+
>;
|
|
185
|
+
currentTurnSurfaces?: Array<{
|
|
186
|
+
surfaceId: string;
|
|
187
|
+
surfaceType: SurfaceType;
|
|
188
|
+
title?: string;
|
|
189
|
+
data: SurfaceData;
|
|
190
|
+
actions?: Array<{ id: string; label: string; style?: string }>;
|
|
191
|
+
}>;
|
|
180
192
|
}
|
|
181
193
|
| undefined;
|
|
182
194
|
}
|
|
@@ -114,7 +114,7 @@ export async function handleGuardianBootstrap(
|
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
if (platform !== "macos" && platform !== "cli") {
|
|
117
|
+
if (platform !== "macos" && platform !== "cli" && platform !== "web") {
|
|
118
118
|
return httpError(
|
|
119
119
|
"BAD_REQUEST",
|
|
120
120
|
"Invalid platform. Bootstrap is macOS/CLI-only; iOS uses QR pairing.",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handler for fetching surface content by ID.
|
|
3
|
+
*
|
|
4
|
+
* GET /v1/surfaces/:surfaceId — return the full surface payload from the
|
|
5
|
+
* session's in-memory surface state. Used by clients to re-hydrate surfaces
|
|
6
|
+
* whose data was stripped during memory compaction.
|
|
7
|
+
*/
|
|
8
|
+
import type { SurfaceData, SurfaceType } from "../../daemon/ipc-contract/surfaces.js";
|
|
9
|
+
import { getLogger } from "../../util/logger.js";
|
|
10
|
+
import { httpError } from "../http-errors.js";
|
|
11
|
+
import type { RouteDefinition } from "../http-router.js";
|
|
12
|
+
|
|
13
|
+
const log = getLogger("surface-content-routes");
|
|
14
|
+
|
|
15
|
+
/** Narrow interface for looking up surface state from a session. */
|
|
16
|
+
interface SurfaceContentTarget {
|
|
17
|
+
surfaceState: Map<
|
|
18
|
+
string,
|
|
19
|
+
{ surfaceType: SurfaceType; data: SurfaceData; title?: string }
|
|
20
|
+
>;
|
|
21
|
+
currentTurnSurfaces?: Array<{
|
|
22
|
+
surfaceId: string;
|
|
23
|
+
surfaceType: SurfaceType;
|
|
24
|
+
title?: string;
|
|
25
|
+
data: SurfaceData;
|
|
26
|
+
actions?: Array<{ id: string; label: string; style?: string }>;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SurfaceContentSessionLookup = (
|
|
31
|
+
sessionId: string,
|
|
32
|
+
) => SurfaceContentTarget | undefined;
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Route definitions
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export function surfaceContentRouteDefinitions(deps: {
|
|
39
|
+
findSession?: SurfaceContentSessionLookup;
|
|
40
|
+
}): RouteDefinition[] {
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
endpoint: "surfaces/:surfaceId",
|
|
44
|
+
method: "GET",
|
|
45
|
+
handler: ({ url, params }) => {
|
|
46
|
+
if (!deps.findSession) {
|
|
47
|
+
return httpError(
|
|
48
|
+
"NOT_IMPLEMENTED",
|
|
49
|
+
"Surface content lookup not available",
|
|
50
|
+
501,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
55
|
+
if (!sessionId) {
|
|
56
|
+
return httpError("BAD_REQUEST", "sessionId query parameter is required", 400);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const surfaceId = params.surfaceId;
|
|
60
|
+
if (!surfaceId) {
|
|
61
|
+
return httpError("BAD_REQUEST", "surfaceId path parameter is required", 400);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const session = deps.findSession(sessionId);
|
|
65
|
+
if (!session) {
|
|
66
|
+
return httpError(
|
|
67
|
+
"NOT_FOUND",
|
|
68
|
+
"No active session found for this sessionId",
|
|
69
|
+
404,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Look up the surface in the session's in-memory state.
|
|
74
|
+
const stored = session.surfaceState.get(surfaceId);
|
|
75
|
+
if (stored) {
|
|
76
|
+
log.info({ sessionId, surfaceId }, "Surface content served from surfaceState");
|
|
77
|
+
return Response.json({
|
|
78
|
+
surfaceId,
|
|
79
|
+
surfaceType: stored.surfaceType,
|
|
80
|
+
title: stored.title ?? null,
|
|
81
|
+
data: stored.data,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fall back to currentTurnSurfaces in case the surface hasn't been
|
|
86
|
+
// committed to surfaceState yet (e.g. mid-turn).
|
|
87
|
+
const turnSurface = session.currentTurnSurfaces?.find(
|
|
88
|
+
(s) => s.surfaceId === surfaceId,
|
|
89
|
+
);
|
|
90
|
+
if (turnSurface) {
|
|
91
|
+
log.info({ sessionId, surfaceId }, "Surface content served from currentTurnSurfaces");
|
|
92
|
+
return Response.json({
|
|
93
|
+
surfaceId,
|
|
94
|
+
surfaceType: turnSurface.surfaceType,
|
|
95
|
+
title: turnSurface.title ?? null,
|
|
96
|
+
data: turnSurface.data,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return httpError("NOT_FOUND", "Surface not found in session", 404);
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|