@vellumai/assistant 0.11.7-dev.202608281503.cbafd6c → 0.11.7-dev.202608282003.8ce5421
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/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
- package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/openapi.yaml +5 -5
- package/package.json +1 -1
- package/src/__tests__/credential-health-service.test.ts +45 -0
- package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
- package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
- package/src/__tests__/guardian-gateway-sim.test.ts +47 -0
- package/src/__tests__/guardian-gateway-sim.ts +5 -1
- package/src/__tests__/non-member-access-request.test.ts +12 -0
- package/src/channels/gateway-guardian-requests.ts +4 -3
- package/src/cli/commands/__tests__/browser.test.ts +9 -7
- package/src/credential-health/credential-health-service.ts +37 -6
- package/src/daemon/lifecycle.ts +10 -13
- package/src/oauth/scope-utils.ts +48 -0
- package/src/persistence/db-connection.ts +3 -21
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
- package/src/persistence/embeddings/embedding-local.ts +7 -112
- package/src/runtime/access-request-helper.ts +15 -7
- package/src/runtime/routes/integrations/slack/channel.ts +39 -18
- package/src/runtime/tool-grant-request-helper.ts +4 -0
- package/src/security/oauth2.ts +2 -11
- package/src/tools/browser/__tests__/browser-status.test.ts +9 -7
- package/src/tools/browser/browser-execution.ts +4 -40
- package/src/tools/browser/browser-status-constants.ts +0 -6
- package/src/util/__tests__/worker-process-command.test.ts +246 -1
- package/src/util/platform.ts +13 -81
- package/src/util/process-table.ts +14 -5
- package/src/util/worker-ownership.ts +126 -0
- package/src/util/worker-process.ts +369 -20
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -461,19 +461,16 @@ export async function runDaemon(): Promise<void> {
|
|
|
461
461
|
);
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
// Expire
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
// kinds (access_request, tool_grant_request) that expired while the
|
|
475
|
-
// daemon was stopped are transitioned so dedup logic doesn't return
|
|
476
|
-
// stale rows.
|
|
464
|
+
// Expire interaction-bound guardian requests (tool_approval,
|
|
465
|
+
// pending_question) left over from before this process started: their
|
|
466
|
+
// in-memory pending-interaction session references are gone, so they
|
|
467
|
+
// can never be completed. Daemon-keyed by design: the daemon triggers
|
|
468
|
+
// the gateway op at its own boot, and the gateway never runs it on its
|
|
469
|
+
// own restart. Persistent kinds (access_request, tool_grant_request) are
|
|
470
|
+
// never touched here, whatever their deadline: their expiry belongs to
|
|
471
|
+
// the periodic sweep, which owns the card-withdrawal and
|
|
472
|
+
// requester-notice fan-out, and the dedupe reads are deadline-aware so
|
|
473
|
+
// a past-deadline row waiting for the sweep suppresses nothing.
|
|
477
474
|
//
|
|
478
475
|
// Startup must not block on the gateway (daemon startup philosophy):
|
|
479
476
|
// on failure the periodic sweep still reaps time-expired rows, and
|
package/src/oauth/scope-utils.ts
CHANGED
|
@@ -24,6 +24,54 @@ export function scopeDifference(
|
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Split a scope list the way the provider writes it.
|
|
29
|
+
*
|
|
30
|
+
* Providers (GitHub, Slack) may return comma-separated scopes regardless of
|
|
31
|
+
* the separator used to join outbound authorize URLs, so the default tolerates
|
|
32
|
+
* both spaces and commas. A provider that explicitly configures a non-default
|
|
33
|
+
* separator (Linear uses ",") is honored, keeping configured scopes
|
|
34
|
+
* round-tripping symmetrically.
|
|
35
|
+
*
|
|
36
|
+
* The one parser for both directions: token responses and authorize
|
|
37
|
+
* parameters. Two copies would let a provider-format fix land on one and not
|
|
38
|
+
* the other.
|
|
39
|
+
*/
|
|
40
|
+
export function parseScopeList(
|
|
41
|
+
raw: string,
|
|
42
|
+
scopeSeparator: string = " ",
|
|
43
|
+
): string[] {
|
|
44
|
+
const splitPattern = scopeSeparator === " " ? /[ ,]/ : scopeSeparator;
|
|
45
|
+
return raw
|
|
46
|
+
.split(splitPattern)
|
|
47
|
+
.map((scope) => scope.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The scopes the stored access token is expected to carry.
|
|
53
|
+
*
|
|
54
|
+
* A provider that asks for user scopes alongside bot scopes issues two grants
|
|
55
|
+
* from one authorization. `exchangeCodeForTokens` stores the `authed_user`
|
|
56
|
+
* token in that case and records that token's own grant as `grantedScopes`, so
|
|
57
|
+
* coverage has to be measured against the request that produced the stored
|
|
58
|
+
* token: `user_scope` where the provider sends one, the `scope` parameter
|
|
59
|
+
* otherwise. Measuring a user grant against the bot request reports scopes the
|
|
60
|
+
* authorization could never place on that token.
|
|
61
|
+
*/
|
|
62
|
+
export function expectedScopesForStoredToken(
|
|
63
|
+
defaultScopes: string[],
|
|
64
|
+
authorizeParams: Record<string, string> | undefined,
|
|
65
|
+
scopeSeparator?: string,
|
|
66
|
+
): string[] {
|
|
67
|
+
const userScope = authorizeParams?.user_scope;
|
|
68
|
+
if (typeof userScope !== "string") {
|
|
69
|
+
return defaultScopes;
|
|
70
|
+
}
|
|
71
|
+
const parsed = parseScopeList(userScope, scopeSeparator);
|
|
72
|
+
return parsed.length > 0 ? parsed : defaultScopes;
|
|
73
|
+
}
|
|
74
|
+
|
|
27
75
|
const GMAIL_FULL_ACCESS_SCOPE = "https://mail.google.com/";
|
|
28
76
|
const GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
|
|
29
77
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { join, sep } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
5
|
|
|
6
|
+
import { canonicalizePathThroughExistingParent } from "@vellumai/environments/test-path-guard";
|
|
6
7
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
7
8
|
|
|
8
9
|
import { getLogsDbPath } from "../util/logs-db-path.js";
|
|
@@ -15,25 +16,6 @@ import { wrapSqliteForSlowQueryLogging } from "./slow-query-log.js";
|
|
|
15
16
|
|
|
16
17
|
export type DrizzleDb = ReturnType<typeof drizzle<typeof schema>>;
|
|
17
18
|
|
|
18
|
-
function canonicalizePathThroughExistingParent(path: string): string {
|
|
19
|
-
const resolvedPath = resolve(path);
|
|
20
|
-
const pendingSegments: string[] = [];
|
|
21
|
-
let currentPath = resolvedPath;
|
|
22
|
-
|
|
23
|
-
while (true) {
|
|
24
|
-
try {
|
|
25
|
-
return resolve(realpathSync(currentPath), ...pendingSegments.reverse());
|
|
26
|
-
} catch {
|
|
27
|
-
const parentPath = dirname(currentPath);
|
|
28
|
-
if (parentPath === currentPath) {
|
|
29
|
-
return resolvedPath;
|
|
30
|
-
}
|
|
31
|
-
pendingSegments.push(basename(currentPath));
|
|
32
|
-
currentPath = parentPath;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
19
|
/**
|
|
38
20
|
* Guard against opening a real workspace database during tests. Shared by
|
|
39
21
|
* every connection opener (main, logs, memory) so a misconfigured test run
|
|
@@ -18,8 +18,10 @@ import { getEmbedWorkerPidPath } from "../../../util/platform.js";
|
|
|
18
18
|
import {
|
|
19
19
|
classifyWorkerOwnership,
|
|
20
20
|
listWorkerProcesses,
|
|
21
|
-
|
|
22
|
-
} from "
|
|
21
|
+
} from "../../../util/worker-ownership.js";
|
|
22
|
+
import { ORPHAN_STOP_TIMEOUT_MS } from "../../../util/worker-process.js";
|
|
23
|
+
import { EMBEDDING_SHUTDOWN_BUDGET_MS } from "../embedding-backend.js";
|
|
24
|
+
import { LocalEmbeddingBackend } from "../embedding-local.js";
|
|
23
25
|
|
|
24
26
|
/** Reach past `private`, which is compile-time only, so tests drive real state. */
|
|
25
27
|
type Internals = any;
|
|
@@ -753,3 +755,14 @@ describe("model matching", () => {
|
|
|
753
755
|
).toEqual([proc.pid]);
|
|
754
756
|
});
|
|
755
757
|
});
|
|
758
|
+
|
|
759
|
+
// The daemon's orphan reclaim SIGTERMs a stale memory worker and waits
|
|
760
|
+
// ORPHAN_STOP_TIMEOUT_MS before SIGKILL. That worker's shutdown reaps the ONNX
|
|
761
|
+
// subprocess it owns, bounded at EMBEDDING_SHUTDOWN_BUDGET_MS + 1s. If the
|
|
762
|
+
// reclaim ceiling does not outlast the reap, reclaiming kills the worker
|
|
763
|
+
// mid-reap and strands the subprocess, which is the JARVIS-1125 failure.
|
|
764
|
+
test("orphan reclaim outlasts the embedding reap it must not truncate", () => {
|
|
765
|
+
expect(ORPHAN_STOP_TIMEOUT_MS).toBeGreaterThan(
|
|
766
|
+
EMBEDDING_SHUTDOWN_BUDGET_MS + 1_000,
|
|
767
|
+
);
|
|
768
|
+
});
|
|
@@ -13,13 +13,15 @@ import {
|
|
|
13
13
|
getEmbeddingModelsDir,
|
|
14
14
|
getEmbedWorkerPidPath,
|
|
15
15
|
} from "../../util/platform.js";
|
|
16
|
-
import {
|
|
17
|
-
listProcessTable,
|
|
18
|
-
readRawProcessCommand,
|
|
19
|
-
} from "../../util/process-table.js";
|
|
16
|
+
import { isProcessAlive } from "../../util/process-liveness.js";
|
|
20
17
|
import { PromiseGuard } from "../../util/promise-guard.js";
|
|
21
18
|
import { workerComputeEnv } from "../../util/worker-compute.js";
|
|
22
19
|
import { workerMemoryEnv } from "../../util/worker-memory.js";
|
|
20
|
+
import {
|
|
21
|
+
classifyWorkerOwnership,
|
|
22
|
+
listWorkerProcesses,
|
|
23
|
+
pid1OwnsWorkers,
|
|
24
|
+
} from "../../util/worker-ownership.js";
|
|
23
25
|
import { EmbeddingRuntimeManager } from "./embedding-runtime-manager.js";
|
|
24
26
|
import {
|
|
25
27
|
type EmbeddingBackend,
|
|
@@ -87,113 +89,6 @@ async function didSettle(
|
|
|
87
89
|
return result !== timeout;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
/** Whether a PID names a live process. */
|
|
91
|
-
function isProcessAlive(pid: number): boolean {
|
|
92
|
-
try {
|
|
93
|
-
// Signal 0 probes for liveness without delivering a signal.
|
|
94
|
-
process.kill(pid, 0);
|
|
95
|
-
return true;
|
|
96
|
-
} catch {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
interface WorkerProcess {
|
|
102
|
-
pid: number;
|
|
103
|
-
ppid: number;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* What this process may do with an embed worker it found in the process table.
|
|
108
|
-
*
|
|
109
|
-
* - `reclaim`: parented to us, a worker we started and lost track of. Reaping
|
|
110
|
-
* it is what enforces one live worker per owning process.
|
|
111
|
-
* - `orphan`: reparented to init, or its owner is gone. Nobody else will ever
|
|
112
|
-
* clean it up.
|
|
113
|
-
* - `foreign`: owned by another live process. The memory-worker process runs
|
|
114
|
-
* its own backend against this same workspace and is entitled to its own
|
|
115
|
-
* worker; signalling it is what made the two replace each other in a loop.
|
|
116
|
-
*
|
|
117
|
-
* A parent of PID 1 is ambiguous, and resolving it wrongly in either direction
|
|
118
|
-
* costs something real. `docker-entrypoint.sh` execs the daemon, so PID 1 can
|
|
119
|
-
* BE the daemon and its child a healthy sibling's worker. Under `docker run
|
|
120
|
-
* --init`, and on any host, PID 1 is an init process instead, so the same
|
|
121
|
-
* parentage means the owner died and the worker needs reclaiming. Callers
|
|
122
|
-
* therefore pass what PID 1 actually is rather than inferring it from whether
|
|
123
|
-
* the deployment is containerized, which is true in both container shapes.
|
|
124
|
-
*/
|
|
125
|
-
export type WorkerOwnership = "reclaim" | "orphan" | "foreign";
|
|
126
|
-
|
|
127
|
-
export function classifyWorkerOwnership(
|
|
128
|
-
worker: WorkerProcess,
|
|
129
|
-
selfPid: number,
|
|
130
|
-
isOwnerAlive: (pid: number) => boolean,
|
|
131
|
-
pid1OwnsWorkers: boolean,
|
|
132
|
-
): WorkerOwnership {
|
|
133
|
-
if (worker.ppid === selfPid) {
|
|
134
|
-
return "reclaim";
|
|
135
|
-
}
|
|
136
|
-
if (worker.ppid <= 1) {
|
|
137
|
-
return pid1OwnsWorkers ? "foreign" : "orphan";
|
|
138
|
-
}
|
|
139
|
-
if (!isOwnerAlive(worker.ppid)) {
|
|
140
|
-
return "orphan";
|
|
141
|
-
}
|
|
142
|
-
return "foreign";
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Entrypoint the daemon is exec'd with, including inside a container. */
|
|
146
|
-
const DAEMON_ENTRYPOINT_MARKER = "daemon/main";
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Whether PID 1 is an assistant daemon rather than an init process.
|
|
150
|
-
*
|
|
151
|
-
* True only where the daemon was exec'd as PID 1 (`docker-entrypoint.sh`),
|
|
152
|
-
* which is what makes a worker parented to 1 a live sibling's rather than an
|
|
153
|
-
* orphan. Under `docker run --init` PID 1 is docker-init and under launchd or
|
|
154
|
-
* systemd it is the system init, so both answer false and PID-1 orphans stay
|
|
155
|
-
* reclaimable. Unreadable means false, which keeps the reclaiming behaviour.
|
|
156
|
-
*/
|
|
157
|
-
function pid1OwnsEmbedWorkers(): boolean {
|
|
158
|
-
if (process.pid === 1) {
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
161
|
-
return readRawProcessCommand(1)?.includes(DAEMON_ENTRYPOINT_MARKER) ?? false;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Live embed-worker processes for this workspace, as `(pid, ppid)` pairs.
|
|
166
|
-
*
|
|
167
|
-
* Ownership of an embed worker is recorded by the OS process tree, not by the
|
|
168
|
-
* PID file: a worker's parent IS its owner. The process table is therefore the
|
|
169
|
-
* authoritative answer to "whose worker is this", and unlike the PID file it
|
|
170
|
-
* survives a crash and cannot be overwritten by a second owner. That matters
|
|
171
|
-
* because the daemon and the memory-worker process legitimately run one worker
|
|
172
|
-
* each against the same workspace.
|
|
173
|
-
*
|
|
174
|
-
* Matches on the absolute worker script path, which lives under THIS
|
|
175
|
-
* workspace's embedding-models directory and is therefore unique per assistant
|
|
176
|
-
* instance, so a sibling instance's workers never match. Raw command lines are
|
|
177
|
-
* read for matching only, never stored or logged: process arguments can carry
|
|
178
|
-
* secrets (see the redaction note in `util/process-tree.ts`).
|
|
179
|
-
*/
|
|
180
|
-
export function listWorkerProcesses(
|
|
181
|
-
workerPath: string,
|
|
182
|
-
model?: string,
|
|
183
|
-
): WorkerProcess[] {
|
|
184
|
-
try {
|
|
185
|
-
return listProcessTable()
|
|
186
|
-
.filter(
|
|
187
|
-
(row) =>
|
|
188
|
-
row.command.includes(workerPath) &&
|
|
189
|
-
(!model || row.command.split(/\s+/).includes(model)),
|
|
190
|
-
)
|
|
191
|
-
.map(({ pid, ppid }) => ({ pid, ppid }));
|
|
192
|
-
} catch {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
92
|
/**
|
|
198
93
|
* Local embedding backend using @huggingface/transformers (ONNX Runtime).
|
|
199
94
|
* Runs BAAI/bge-small-en-v1.5 locally — no API calls, no network required.
|
|
@@ -874,7 +769,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
874
769
|
worker,
|
|
875
770
|
process.pid,
|
|
876
771
|
isProcessAlive,
|
|
877
|
-
|
|
772
|
+
pid1OwnsWorkers(),
|
|
878
773
|
);
|
|
879
774
|
if (ownership === "foreign") {
|
|
880
775
|
continue;
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* principal so access requests cannot bind to stale/cross-assistant contacts.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { isGuardianRequestExpired } from "@vellumai/gateway-client";
|
|
15
|
+
|
|
14
16
|
import {
|
|
15
17
|
createGuardianRequest,
|
|
16
18
|
listGuardianRequestsOrEmpty,
|
|
@@ -228,13 +230,19 @@ export async function notifyGuardianOfAccessRequest(
|
|
|
228
230
|
// notified: true with the existing request ID so callers know the guardian
|
|
229
231
|
// was already notified. A degraded (empty) read falls through to creation,
|
|
230
232
|
// whose fail-closed throw prevents a prompt without a persisted request.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
233
|
+
// Past-deadline rows never dedupe: a pending row can sit past its
|
|
234
|
+
// expiresAt until the sweep expires it, it is already undecidable, and
|
|
235
|
+
// letting it absorb a fresh attempt would silently drop the new guardian
|
|
236
|
+
// notification.
|
|
237
|
+
const existingPending = (
|
|
238
|
+
await listGuardianRequestsOrEmpty({
|
|
239
|
+
status: "pending",
|
|
240
|
+
requesterExternalUserId: actorExternalId,
|
|
241
|
+
sourceChannel,
|
|
242
|
+
kind: "access_request",
|
|
243
|
+
sourceConversationId: conversationId,
|
|
244
|
+
})
|
|
245
|
+
).filter((r) => !isGuardianRequestExpired(r));
|
|
238
246
|
if (existingPending.length > 0) {
|
|
239
247
|
log.debug(
|
|
240
248
|
{ sourceChannel, actorExternalId, existingId: existingPending[0].id },
|
|
@@ -19,8 +19,39 @@ import {
|
|
|
19
19
|
} from "../../../../daemon/handlers/config-slack-channel.js";
|
|
20
20
|
import { ACTOR_PRINCIPALS } from "../../../auth/route-policy.js";
|
|
21
21
|
import { BadRequestError } from "../../errors.js";
|
|
22
|
+
import { parseBody } from "../../parse-body.js";
|
|
22
23
|
import type { RouteDefinition, RouteHandlerArgs } from "../../types.js";
|
|
23
24
|
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Body schemas
|
|
27
|
+
//
|
|
28
|
+
// Each is named once and used twice: as the route's `requestBody`, a codegen
|
|
29
|
+
// signal only, and by `parseBody` in the handler, which is what rejects a
|
|
30
|
+
// malformed request. One declaration keeps the wire contract and the runtime
|
|
31
|
+
// check in agreement.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every field is optional because `setSlackChannelConfig` accepts any subset:
|
|
36
|
+
* a user token alone is a valid update that leaves the bot and app tokens in
|
|
37
|
+
* place. The wizard requires both bot and app tokens, and enforces that in
|
|
38
|
+
* `use-save-slack-config.ts` where that requirement belongs.
|
|
39
|
+
*/
|
|
40
|
+
const SetSlackChannelConfigBody = z.object({
|
|
41
|
+
botToken: z.string().optional().describe("Slack bot token"),
|
|
42
|
+
appToken: z.string().optional().describe("Slack app-level token"),
|
|
43
|
+
userToken: z
|
|
44
|
+
.string()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Slack user token, for reading channels the bot is not in"),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const PatchSlackChannelConfigBody = z.object({
|
|
50
|
+
threadMode: SlackThreadMode.describe(
|
|
51
|
+
"Controls whether the bot follows threads after an initial @mention",
|
|
52
|
+
).optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
24
55
|
// ---------------------------------------------------------------------------
|
|
25
56
|
// Handlers
|
|
26
57
|
// ---------------------------------------------------------------------------
|
|
@@ -32,22 +63,19 @@ async function handleGetSlackChannelConfig() {
|
|
|
32
63
|
export async function handleSetSlackChannelConfig({
|
|
33
64
|
body = {},
|
|
34
65
|
}: RouteHandlerArgs) {
|
|
35
|
-
const { botToken, appToken, userToken } =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
};
|
|
66
|
+
const { botToken, appToken, userToken } = parseBody(
|
|
67
|
+
SetSlackChannelConfigBody,
|
|
68
|
+
body,
|
|
69
|
+
);
|
|
40
70
|
const result = await setSlackChannelConfig(botToken, appToken, userToken);
|
|
41
71
|
if (!result.success) {
|
|
42
|
-
throw new BadRequestError(
|
|
43
|
-
(result as { error?: string }).error ?? "Failed to set Slack config",
|
|
44
|
-
);
|
|
72
|
+
throw new BadRequestError(result.error ?? "Failed to set Slack config");
|
|
45
73
|
}
|
|
46
74
|
return result;
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
async function handlePatchSlackChannelConfig({ body = {} }: RouteHandlerArgs) {
|
|
50
|
-
const { threadMode } = body
|
|
78
|
+
const { threadMode } = parseBody(PatchSlackChannelConfigBody, body);
|
|
51
79
|
if (threadMode !== undefined) {
|
|
52
80
|
const parsed = SlackThreadMode.safeParse(threadMode);
|
|
53
81
|
if (!parsed.success) {
|
|
@@ -95,10 +123,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
95
123
|
description: "Validate and store Slack channel credentials.",
|
|
96
124
|
tags: ["integrations"],
|
|
97
125
|
handler: handleSetSlackChannelConfig,
|
|
98
|
-
requestBody:
|
|
99
|
-
botToken: z.string().describe("Slack bot token"),
|
|
100
|
-
appToken: z.string().describe("Slack app-level token"),
|
|
101
|
-
}),
|
|
126
|
+
requestBody: SetSlackChannelConfigBody,
|
|
102
127
|
responseBody: SlackChannelConfigResultSchema,
|
|
103
128
|
},
|
|
104
129
|
{
|
|
@@ -113,11 +138,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
113
138
|
description: "Update Slack channel behavior settings (e.g. thread mode).",
|
|
114
139
|
tags: ["integrations"],
|
|
115
140
|
handler: handlePatchSlackChannelConfig,
|
|
116
|
-
requestBody:
|
|
117
|
-
threadMode: SlackThreadMode.describe(
|
|
118
|
-
"Controls whether the bot follows threads after an initial @mention",
|
|
119
|
-
).optional(),
|
|
120
|
-
}),
|
|
141
|
+
requestBody: PatchSlackChannelConfigBody,
|
|
121
142
|
responseBody: SlackChannelConfigResultSchema,
|
|
122
143
|
},
|
|
123
144
|
{
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* - Notification routing goes through emitNotificationSignal().
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { isGuardianRequestExpired } from "@vellumai/gateway-client";
|
|
15
|
+
|
|
14
16
|
import {
|
|
15
17
|
createGuardianRequest,
|
|
16
18
|
listGuardianRequestsOrEmpty,
|
|
@@ -124,8 +126,10 @@ export async function createOrReuseToolGrantRequest(
|
|
|
124
126
|
kind: "tool_grant_request",
|
|
125
127
|
toolName,
|
|
126
128
|
});
|
|
129
|
+
// Past-deadline rows never dedupe: see the access-request helper's note.
|
|
127
130
|
const dedupeMatch = existing.find(
|
|
128
131
|
(r) =>
|
|
132
|
+
!isGuardianRequestExpired(r) &&
|
|
129
133
|
r.inputDigest === inputDigest &&
|
|
130
134
|
r.guardianExternalUserId === binding.guardianExternalUserId,
|
|
131
135
|
);
|
package/src/security/oauth2.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
20
20
|
import { createServer, type Server } from "node:http";
|
|
21
21
|
|
|
22
22
|
import { getIsPlatform } from "../config/env-registry.js";
|
|
23
|
+
import { parseScopeList } from "../oauth/scope-utils.js";
|
|
23
24
|
import { getLogger } from "../util/logger.js";
|
|
24
25
|
import { renderOAuthCompletionPage as renderLoopbackPage } from "./oauth-completion-page.js";
|
|
25
26
|
|
|
@@ -249,19 +250,9 @@ export async function exchangeCodeForTokens(
|
|
|
249
250
|
(tokenData.token_type as string | undefined),
|
|
250
251
|
};
|
|
251
252
|
|
|
252
|
-
// Defensive split: providers (e.g. GitHub, Slack) may return comma-separated
|
|
253
|
-
// scopes in token responses regardless of the scope_separator used to join
|
|
254
|
-
// outbound authorize URLs, so we tolerate both spaces and commas here. When
|
|
255
|
-
// a provider explicitly configures a non-default separator (e.g. Linear uses
|
|
256
|
-
// ","), we honor that to keep symmetric round-tripping of configured scopes.
|
|
257
|
-
const splitPattern =
|
|
258
|
-
config.scopeSeparator === " " ? /[ ,]/ : config.scopeSeparator;
|
|
259
253
|
const grantedScopes =
|
|
260
254
|
typeof tokens.scope === "string"
|
|
261
|
-
? tokens.scope
|
|
262
|
-
.split(splitPattern)
|
|
263
|
-
.map((s) => s.trim())
|
|
264
|
-
.filter(Boolean)
|
|
255
|
+
? parseScopeList(tokens.scope, config.scopeSeparator)
|
|
265
256
|
: [...config.scopes];
|
|
266
257
|
|
|
267
258
|
return { tokens, grantedScopes, rawTokenResponse: tokenData };
|
|
@@ -5,7 +5,7 @@ import type { ToolContext } from "../../types.js";
|
|
|
5
5
|
import {
|
|
6
6
|
BROWSER_STATUS_INPUT_FIELD,
|
|
7
7
|
BROWSER_STATUS_MODE,
|
|
8
|
-
|
|
8
|
+
CHROME_EXTENSION_INSTALL_HINT,
|
|
9
9
|
} from "../browser-status-constants.js";
|
|
10
10
|
import { CdpError } from "../cdp-client/errors.js";
|
|
11
11
|
|
|
@@ -215,9 +215,10 @@ describe("executeBrowserStatus", () => {
|
|
|
215
215
|
expect(extension.summary).toContain("no Chrome Extension is connected");
|
|
216
216
|
expect(extension.verified).toBe("preflight");
|
|
217
217
|
expect(extension.details.transport).toBe("extension-ws");
|
|
218
|
-
expect(extension.userActions
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
expect(extension.userActions).toEqual([
|
|
219
|
+
CHROME_EXTENSION_INSTALL_HINT,
|
|
220
|
+
"Tell the user to make sure a browser is open with the Vellum Chrome extension on.",
|
|
221
|
+
]);
|
|
221
222
|
});
|
|
222
223
|
|
|
223
224
|
test("probe failure diagnostics include remediation actions", async () => {
|
|
@@ -241,9 +242,10 @@ describe("executeBrowserStatus", () => {
|
|
|
241
242
|
expect(extension).toBeDefined();
|
|
242
243
|
expect(extension.available).toBe(false);
|
|
243
244
|
expect(extension.summary).toContain("probe failed");
|
|
244
|
-
expect(extension.userActions
|
|
245
|
-
|
|
246
|
-
|
|
245
|
+
expect(extension.userActions).toEqual([
|
|
246
|
+
CHROME_EXTENSION_INSTALL_HINT,
|
|
247
|
+
"Tell the user to make sure a browser is open with the Vellum Chrome extension on.",
|
|
248
|
+
]);
|
|
247
249
|
});
|
|
248
250
|
|
|
249
251
|
test("recommendation order follows auto candidate precedence with available extension", async () => {
|
|
@@ -41,7 +41,6 @@ import {
|
|
|
41
41
|
CDP_INSPECT_STATUS_DISCOVERY_CODE,
|
|
42
42
|
CHROME_EXTENSION_INSTALL_HINT,
|
|
43
43
|
CHROME_WEB_STORE_INSTALL_URL,
|
|
44
|
-
EXTENSION_STATUS_ERROR_MARKER,
|
|
45
44
|
} from "./browser-status-constants.js";
|
|
46
45
|
import {
|
|
47
46
|
formatAxSnapshot,
|
|
@@ -2488,10 +2487,10 @@ function modeTradeoffs(mode: StatusCheckMode): string[] {
|
|
|
2488
2487
|
return MODE_TRADEOFFS[mode];
|
|
2489
2488
|
}
|
|
2490
2489
|
|
|
2491
|
-
function
|
|
2490
|
+
function extensionConnectionActions(): string[] {
|
|
2492
2491
|
return [
|
|
2493
2492
|
CHROME_EXTENSION_INSTALL_HINT,
|
|
2494
|
-
"
|
|
2493
|
+
"Tell the user to make sure a browser is open with the Vellum Chrome extension on.",
|
|
2495
2494
|
];
|
|
2496
2495
|
}
|
|
2497
2496
|
|
|
@@ -2520,49 +2519,14 @@ function extractDiscoveryCodes(error: CdpError): string[] {
|
|
|
2520
2519
|
return dedupeStrings(codes);
|
|
2521
2520
|
}
|
|
2522
2521
|
|
|
2523
|
-
function containsTokenCaseInsensitive(text: string, token: string): boolean {
|
|
2524
|
-
return text.toLowerCase().includes(token.toLowerCase());
|
|
2525
|
-
}
|
|
2526
|
-
|
|
2527
2522
|
function probeFailureActions(mode: StatusCheckMode, error: CdpError): string[] {
|
|
2528
2523
|
const actions: string[] = [];
|
|
2529
|
-
const message = error.message.toLowerCase();
|
|
2530
2524
|
const discoveryCodes = extractDiscoveryCodes(error).map((c) =>
|
|
2531
2525
|
c.toLowerCase(),
|
|
2532
2526
|
);
|
|
2533
2527
|
|
|
2534
2528
|
if (mode === BROWSER_STATUS_MODE.EXTENSION) {
|
|
2535
|
-
actions.push(...
|
|
2536
|
-
if (
|
|
2537
|
-
containsTokenCaseInsensitive(
|
|
2538
|
-
message,
|
|
2539
|
-
EXTENSION_STATUS_ERROR_MARKER.UNAUTHORIZED_ORIGIN,
|
|
2540
|
-
)
|
|
2541
|
-
) {
|
|
2542
|
-
actions.push(
|
|
2543
|
-
"Ensure this extension ID is present in chrome-extension-allowlist.local.json in $GATEWAY_SECURITY_DIR and restart the assistant.",
|
|
2544
|
-
);
|
|
2545
|
-
}
|
|
2546
|
-
if (
|
|
2547
|
-
containsTokenCaseInsensitive(
|
|
2548
|
-
message,
|
|
2549
|
-
EXTENSION_STATUS_ERROR_MARKER.NATIVE_MESSAGING_HOST,
|
|
2550
|
-
)
|
|
2551
|
-
) {
|
|
2552
|
-
actions.push(
|
|
2553
|
-
"Reinstall the native messaging host manifest and confirm it allows this extension ID.",
|
|
2554
|
-
);
|
|
2555
|
-
}
|
|
2556
|
-
if (
|
|
2557
|
-
containsTokenCaseInsensitive(
|
|
2558
|
-
message,
|
|
2559
|
-
EXTENSION_STATUS_ERROR_MARKER.HTTP_401,
|
|
2560
|
-
)
|
|
2561
|
-
) {
|
|
2562
|
-
actions.push(
|
|
2563
|
-
"Re-pair the extension so it refreshes its local relay credential.",
|
|
2564
|
-
);
|
|
2565
|
-
}
|
|
2529
|
+
actions.push(...extensionConnectionActions());
|
|
2566
2530
|
}
|
|
2567
2531
|
|
|
2568
2532
|
if (mode === BROWSER_STATUS_MODE.CDP_INSPECT) {
|
|
@@ -2654,7 +2618,7 @@ async function checkExtensionModeStatus(
|
|
|
2654
2618
|
autoCandidate,
|
|
2655
2619
|
summary:
|
|
2656
2620
|
"Extension mode is unavailable: no Chrome Extension is connected.",
|
|
2657
|
-
userActions:
|
|
2621
|
+
userActions: extensionConnectionActions(),
|
|
2658
2622
|
tradeoffs: modeTradeoffs(BROWSER_STATUS_MODE.EXTENSION),
|
|
2659
2623
|
details: { transport: "extension-ws" },
|
|
2660
2624
|
};
|
|
@@ -20,12 +20,6 @@ export const BROWSER_STATUS_INPUT_FIELD = {
|
|
|
20
20
|
CHECK_LOCAL_LAUNCH: "check_local_launch",
|
|
21
21
|
} as const;
|
|
22
22
|
|
|
23
|
-
export const EXTENSION_STATUS_ERROR_MARKER = {
|
|
24
|
-
UNAUTHORIZED_ORIGIN: "unauthorized_origin",
|
|
25
|
-
NATIVE_MESSAGING_HOST: "native messaging host",
|
|
26
|
-
HTTP_401: "401",
|
|
27
|
-
} as const;
|
|
28
|
-
|
|
29
23
|
/** Chrome Web Store listing for the Vellum Assistant browser extension. */
|
|
30
24
|
export const CHROME_WEB_STORE_INSTALL_URL =
|
|
31
25
|
"https://chromewebstore.google.com/detail/vellum-assistant-browser/hphbdmpffeigpcdjkckleobjmhhokpne";
|