@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/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
66
66
|
* That irregularity is why this is stated rather than derived from the key,
|
|
67
67
|
* and it is stated here because this file already owns what a channel is.
|
|
68
68
|
*
|
|
69
|
+
* Both senses can hold a user token, which is the sharpest edge. The `slack`
|
|
70
|
+
* integration's persisted token *is* the installer's user token, held on its
|
|
71
|
+
* OAuth connection. `slack_channel` holds an optional `user_token` in the
|
|
72
|
+
* credential store, beside its bot and app tokens. Same words, different
|
|
73
|
+
* homes, and only the second is a credential-store key: a pasted token is
|
|
74
|
+
* always the channel's, because the integration's never leaves the exchange.
|
|
75
|
+
*
|
|
69
76
|
* Deliberately only the key. What fields each credential requires is declared
|
|
70
77
|
* once already, per service, in the gateway's credential specs; restating it
|
|
71
78
|
* here would be a second copy of a different fact.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
assertTestPathIsEphemeral,
|
|
8
|
+
canonicalizePathThroughExistingParent,
|
|
9
|
+
isBunTestProcess,
|
|
10
|
+
} from "../test-path-guard.js";
|
|
11
|
+
|
|
12
|
+
const OPTIONS = {
|
|
13
|
+
allowEnvVar: "TEST_PATH_GUARD_ALLOW",
|
|
14
|
+
runHint: "Run tests from the package root.",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("isBunTestProcess", () => {
|
|
18
|
+
test("detects this bun test run", () => {
|
|
19
|
+
expect(isBunTestProcess()).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("canonicalizePathThroughExistingParent", () => {
|
|
24
|
+
test("resolves symlinks on the deepest existing ancestor", () => {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), "test-path-guard-"));
|
|
26
|
+
try {
|
|
27
|
+
const target = join(root, "target");
|
|
28
|
+
mkdirSync(target);
|
|
29
|
+
const link = join(root, "link");
|
|
30
|
+
symlinkSync(target, link, "dir");
|
|
31
|
+
expect(
|
|
32
|
+
canonicalizePathThroughExistingParent(join(link, "missing", "leaf")),
|
|
33
|
+
).toBe(
|
|
34
|
+
join(canonicalizePathThroughExistingParent(target), "missing", "leaf"),
|
|
35
|
+
);
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(root, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns the input path when no ancestor exists", () => {
|
|
42
|
+
expect(canonicalizePathThroughExistingParent("/nonexistent-root-x/y")).toBe(
|
|
43
|
+
"/nonexistent-root-x/y",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("assertTestPathIsEphemeral", () => {
|
|
49
|
+
test("allows paths under tmpdir, existing or not", () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
assertTestPathIsEphemeral(join(tmpdir(), "guard-missing", "x"), OPTIONS),
|
|
52
|
+
).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("refuses paths outside tmpdir, naming the escape hatch and hint", () => {
|
|
56
|
+
delete process.env[OPTIONS.allowEnvVar];
|
|
57
|
+
const dir = join(homedir(), "test-path-guard-live");
|
|
58
|
+
expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
|
|
59
|
+
/Refusing to use[\s\S]*package root[\s\S]*TEST_PATH_GUARD_ALLOW/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("refuses a tmpdir symlink that resolves outside tmpdir", () => {
|
|
64
|
+
delete process.env[OPTIONS.allowEnvVar];
|
|
65
|
+
const link = join(tmpdir(), `test-path-guard-escape-${process.pid}`);
|
|
66
|
+
rmSync(link, { force: true });
|
|
67
|
+
symlinkSync(homedir(), link);
|
|
68
|
+
try {
|
|
69
|
+
expect(() => assertTestPathIsEphemeral(link, OPTIONS)).toThrow(
|
|
70
|
+
/Refusing to use/,
|
|
71
|
+
);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(link, { force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the allow env var bypasses the guard, per call", () => {
|
|
78
|
+
const dir = join(homedir(), "test-path-guard-optout");
|
|
79
|
+
process.env[OPTIONS.allowEnvVar] = "1";
|
|
80
|
+
try {
|
|
81
|
+
expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).not.toThrow();
|
|
82
|
+
} finally {
|
|
83
|
+
delete process.env[OPTIONS.allowEnvVar];
|
|
84
|
+
}
|
|
85
|
+
expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
|
|
86
|
+
/Refusing to use/,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-path guard for test processes, shared by every service that resolves
|
|
3
|
+
* install-layout paths (the assistant daemon's workspace/root, the gateway's
|
|
4
|
+
* workspace and security dirs).
|
|
5
|
+
*
|
|
6
|
+
* A test process must never resolve one of those paths to a real, non-temp
|
|
7
|
+
* directory: production code exercised by a test would then read and
|
|
8
|
+
* destructively write live state. The tmpdir redirection normally comes from
|
|
9
|
+
* each package's bunfig.toml test preload, but bun only loads bunfig from
|
|
10
|
+
* the cwd, so `bun test` run from any other directory skips the preload and
|
|
11
|
+
* inherits the ambient env (or a ~/.vellum fallback). The containment
|
|
12
|
+
* assertion therefore lives in production code, where it fires no matter how
|
|
13
|
+
* the test process was launched.
|
|
14
|
+
*
|
|
15
|
+
* assistant/src/__tests__/assert-not-live-db.ts keeps its own containment
|
|
16
|
+
* check: test machinery must not import production dependencies.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { realpathSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { basename, dirname, resolve, sep } from "node:path";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve symlinks in the deepest existing ancestor of `path`, then
|
|
25
|
+
* re-append the not-yet-created tail. This keeps containment checks honest
|
|
26
|
+
* both for paths under a symlinked temp root (macOS /var/folders) and for
|
|
27
|
+
* symlinks that point outside it, whether or not the leaf exists yet.
|
|
28
|
+
*/
|
|
29
|
+
export function canonicalizePathThroughExistingParent(path: string): string {
|
|
30
|
+
const resolvedPath = resolve(path);
|
|
31
|
+
const pendingSegments: string[] = [];
|
|
32
|
+
let currentPath = resolvedPath;
|
|
33
|
+
|
|
34
|
+
while (true) {
|
|
35
|
+
try {
|
|
36
|
+
return resolve(realpathSync(currentPath), ...pendingSegments.reverse());
|
|
37
|
+
} catch {
|
|
38
|
+
const parentPath = dirname(currentPath);
|
|
39
|
+
if (parentPath === currentPath) {
|
|
40
|
+
return resolvedPath;
|
|
41
|
+
}
|
|
42
|
+
pendingSegments.push(basename(currentPath));
|
|
43
|
+
currentPath = parentPath;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Lazily computed: is this process a `bun test` run? */
|
|
49
|
+
let isTestProcess: boolean | undefined;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when this process is a `bun test` run. Computed once and cached for
|
|
53
|
+
* the process lifetime, so a runtime flip of NODE_ENV or BUN_TEST does not
|
|
54
|
+
* change the answer. Test-mode behavior toggles that must observe such
|
|
55
|
+
* flips read the env directly instead of calling this.
|
|
56
|
+
*/
|
|
57
|
+
export function isBunTestProcess(): boolean {
|
|
58
|
+
isTestProcess ??=
|
|
59
|
+
process.env.NODE_ENV === "test" ||
|
|
60
|
+
process.env.BUN_TEST === "1" ||
|
|
61
|
+
// `bun test` sets NODE_ENV=test only when unset; Bun.main being the test
|
|
62
|
+
// file itself is the backstop signal that survives a preset NODE_ENV.
|
|
63
|
+
(typeof Bun !== "undefined" &&
|
|
64
|
+
/\.(test|spec)\.[cm]?[jt]sx?$/.test(Bun.main));
|
|
65
|
+
return isTestProcess;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EphemeralTestPathOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Escape hatch for the rare intentional run against real state: setting
|
|
71
|
+
* this env var to "1" bypasses the guard. Callers reuse the variable their
|
|
72
|
+
* DB-open guard already honors. Deliberately not forwarded into
|
|
73
|
+
* agent-spawned shells (assistant/src/tools/terminal/safe-env.ts), so a
|
|
74
|
+
* daemon-level opt-out cannot disarm the guard for tests run from there.
|
|
75
|
+
*/
|
|
76
|
+
allowEnvVar: string;
|
|
77
|
+
/** Package-specific line telling the reader where to run tests from. */
|
|
78
|
+
runHint: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Throws when called in a bun-test process and `dir` does not resolve under
|
|
83
|
+
* `os.tmpdir()`. Inert outside test processes. Canonicalizes on every call
|
|
84
|
+
* (no memo): a previously validated path could later be replaced by a
|
|
85
|
+
* symlink, and the guard only runs in test processes anyway.
|
|
86
|
+
*/
|
|
87
|
+
export function assertTestPathIsEphemeral(
|
|
88
|
+
dir: string,
|
|
89
|
+
options: EphemeralTestPathOptions,
|
|
90
|
+
): void {
|
|
91
|
+
if (!isBunTestProcess()) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (process.env[options.allowEnvVar] === "1") {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const tmpRoot = canonicalizePathThroughExistingParent(tmpdir());
|
|
98
|
+
const resolved = canonicalizePathThroughExistingParent(dir);
|
|
99
|
+
if (resolved !== tmpRoot && !resolved.startsWith(tmpRoot + sep)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
[
|
|
102
|
+
`Refusing to use ${dir} (resolves to ${resolved}) in a test process: it is not under the temp directory (${tmpRoot}).`,
|
|
103
|
+
"",
|
|
104
|
+
"Tests must only touch ephemeral state; a real directory would expose",
|
|
105
|
+
"live install state to destructive test fixtures. This usually means",
|
|
106
|
+
"`bun test` ran from a cwd without the package's bunfig.toml, so the",
|
|
107
|
+
"test preload that redirects paths to a tmpdir never loaded.",
|
|
108
|
+
options.runHint,
|
|
109
|
+
`Set ${options.allowEnvVar}=1 to bypass deliberately.`,
|
|
110
|
+
].join("\n"),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
66
66
|
* That irregularity is why this is stated rather than derived from the key,
|
|
67
67
|
* and it is stated here because this file already owns what a channel is.
|
|
68
68
|
*
|
|
69
|
+
* Both senses can hold a user token, which is the sharpest edge. The `slack`
|
|
70
|
+
* integration's persisted token *is* the installer's user token, held on its
|
|
71
|
+
* OAuth connection. `slack_channel` holds an optional `user_token` in the
|
|
72
|
+
* credential store, beside its bot and app tokens. Same words, different
|
|
73
|
+
* homes, and only the second is a credential-store key: a pasted token is
|
|
74
|
+
* always the channel's, because the integration's never leaves the exchange.
|
|
75
|
+
*
|
|
69
76
|
* Deliberately only the key. What fields each credential requires is declared
|
|
70
77
|
* once already, per service, in the gateway's credential specs; restating it
|
|
71
78
|
* here would be a second copy of a different fact.
|
|
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
66
66
|
* That irregularity is why this is stated rather than derived from the key,
|
|
67
67
|
* and it is stated here because this file already owns what a channel is.
|
|
68
68
|
*
|
|
69
|
+
* Both senses can hold a user token, which is the sharpest edge. The `slack`
|
|
70
|
+
* integration's persisted token *is* the installer's user token, held on its
|
|
71
|
+
* OAuth connection. `slack_channel` holds an optional `user_token` in the
|
|
72
|
+
* credential store, beside its bot and app tokens. Same words, different
|
|
73
|
+
* homes, and only the second is a credential-store key: a pasted token is
|
|
74
|
+
* always the channel's, because the integration's never leaves the exchange.
|
|
75
|
+
*
|
|
69
76
|
* Deliberately only the key. What fields each credential requires is declared
|
|
70
77
|
* once already, per service, in the gateway's credential specs; restating it
|
|
71
78
|
* here would be a second copy of a different fact.
|
package/openapi.yaml
CHANGED
|
@@ -16253,14 +16253,14 @@ paths:
|
|
|
16253
16253
|
type: object
|
|
16254
16254
|
properties:
|
|
16255
16255
|
botToken:
|
|
16256
|
-
type: string
|
|
16257
16256
|
description: Slack bot token
|
|
16258
|
-
appToken:
|
|
16259
16257
|
type: string
|
|
16258
|
+
appToken:
|
|
16260
16259
|
description: Slack app-level token
|
|
16261
|
-
|
|
16262
|
-
|
|
16263
|
-
|
|
16260
|
+
type: string
|
|
16261
|
+
userToken:
|
|
16262
|
+
description: Slack user token, for reading channels the bot is not in
|
|
16263
|
+
type: string
|
|
16264
16264
|
responses:
|
|
16265
16265
|
"200":
|
|
16266
16266
|
description: Successful response
|
package/package.json
CHANGED
|
@@ -12,6 +12,8 @@ let mockProviders: Array<{
|
|
|
12
12
|
pingMethod: string | null;
|
|
13
13
|
pingHeaders: string | null;
|
|
14
14
|
pingBody: string | null;
|
|
15
|
+
authorizeParams: string | null;
|
|
16
|
+
scopeSeparator: string | null;
|
|
15
17
|
managedServiceConfigKey?: string;
|
|
16
18
|
}> = [];
|
|
17
19
|
|
|
@@ -191,11 +193,16 @@ function addProvider(
|
|
|
191
193
|
pingUrl?: string | null;
|
|
192
194
|
pingMethod?: string | null;
|
|
193
195
|
managedServiceConfigKey?: string;
|
|
196
|
+
authorizeParams?: Record<string, string>;
|
|
194
197
|
},
|
|
195
198
|
) {
|
|
196
199
|
mockProviders.push({
|
|
197
200
|
provider,
|
|
198
201
|
defaultScopes: JSON.stringify(opts?.defaultScopes ?? []),
|
|
202
|
+
authorizeParams: opts?.authorizeParams
|
|
203
|
+
? JSON.stringify(opts.authorizeParams)
|
|
204
|
+
: null,
|
|
205
|
+
scopeSeparator: " ",
|
|
199
206
|
pingUrl: opts?.pingUrl ?? null,
|
|
200
207
|
pingMethod: opts?.pingMethod ?? null,
|
|
201
208
|
pingHeaders: null,
|
|
@@ -443,6 +450,44 @@ describe("credential-health-service", () => {
|
|
|
443
450
|
expect(report.results[0]!.canAutoRecover).toBe(false);
|
|
444
451
|
});
|
|
445
452
|
|
|
453
|
+
test("measures a user-token grant against user_scope, not the bot scopes", async () => {
|
|
454
|
+
// Slack issues two grants from one authorization and the stored token is
|
|
455
|
+
// the user one, so its grant is compared against `user_scope`. Measuring
|
|
456
|
+
// it against the bot `scope` parameter reports scopes that grant can never
|
|
457
|
+
// hold: `channels:join` is bot-only and is never requested for the user.
|
|
458
|
+
addProvider("slack", {
|
|
459
|
+
defaultScopes: ["channels:join", "chat:write", "search:read"],
|
|
460
|
+
authorizeParams: { user_scope: "chat:write,search:read" },
|
|
461
|
+
});
|
|
462
|
+
addConnection("slack", "conn-1", {
|
|
463
|
+
grantedScopes: ["chat:write", "search:read"],
|
|
464
|
+
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
|
465
|
+
});
|
|
466
|
+
setToken("conn-1");
|
|
467
|
+
|
|
468
|
+
const report = await checkAllCredentials();
|
|
469
|
+
expect(report.results[0]!.status).not.toBe("missing_scopes");
|
|
470
|
+
expect(report.results[0]!.missingScopes).toEqual([]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("still reports a user scope the installer did not grant", async () => {
|
|
474
|
+
// Sensitivity check for the test above: comparing against `user_scope`
|
|
475
|
+
// must keep catching a genuinely declined user scope, not pass everything.
|
|
476
|
+
addProvider("slack", {
|
|
477
|
+
defaultScopes: ["channels:join", "chat:write", "search:read"],
|
|
478
|
+
authorizeParams: { user_scope: "chat:write,search:read" },
|
|
479
|
+
});
|
|
480
|
+
addConnection("slack", "conn-1", {
|
|
481
|
+
grantedScopes: ["chat:write"],
|
|
482
|
+
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
|
483
|
+
});
|
|
484
|
+
setToken("conn-1");
|
|
485
|
+
|
|
486
|
+
const report = await checkAllCredentials();
|
|
487
|
+
expect(report.results[0]!.status).toBe("missing_scopes");
|
|
488
|
+
expect(report.results[0]!.missingScopes).toEqual(["search:read"]);
|
|
489
|
+
});
|
|
490
|
+
|
|
446
491
|
test("returns revoked when ping returns 401", async () => {
|
|
447
492
|
mockFetchResponse = { ok: false, status: 401 };
|
|
448
493
|
addProvider("google", {
|
|
@@ -3,8 +3,6 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
|
3
3
|
|
|
4
4
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
5
|
|
|
6
|
-
const originalBunTest = process.env.BUN_TEST;
|
|
7
|
-
|
|
8
6
|
import { getSqliteFrom } from "../persistence/db-connection.js";
|
|
9
7
|
import { migrateConversationForkLineage } from "../persistence/migrations/183-add-conversation-fork-lineage.js";
|
|
10
8
|
import * as schema from "../persistence/schema/index.js";
|
|
@@ -62,12 +60,10 @@ function resetMigrationTestDb(): void {
|
|
|
62
60
|
|
|
63
61
|
describe("conversation fork lineage migration", () => {
|
|
64
62
|
beforeEach(() => {
|
|
65
|
-
process.env.BUN_TEST = "0";
|
|
66
63
|
resetMigrationTestDb();
|
|
67
64
|
});
|
|
68
65
|
|
|
69
66
|
afterAll(() => {
|
|
70
|
-
process.env.BUN_TEST = originalBunTest;
|
|
71
67
|
resetMigrationTestDb();
|
|
72
68
|
});
|
|
73
69
|
|
|
@@ -3,8 +3,6 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
|
3
3
|
|
|
4
4
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
5
|
|
|
6
|
-
const originalBunTest = process.env.BUN_TEST;
|
|
7
|
-
|
|
8
6
|
import { _resetDisplayOrderMigrationForTests } from "../persistence/conversation-display-order-migration.js";
|
|
9
7
|
import { _resetGroupMigrationForTests } from "../persistence/conversation-group-migration.js";
|
|
10
8
|
import { getSqliteFrom } from "../persistence/db-connection.js";
|
|
@@ -68,12 +66,10 @@ function resetMigrationTestDb(): void {
|
|
|
68
66
|
|
|
69
67
|
describe("conversation inference profile migration", () => {
|
|
70
68
|
beforeEach(() => {
|
|
71
|
-
process.env.BUN_TEST = "0";
|
|
72
69
|
resetMigrationTestDb();
|
|
73
70
|
});
|
|
74
71
|
|
|
75
72
|
afterAll(() => {
|
|
76
|
-
process.env.BUN_TEST = originalBunTest;
|
|
77
73
|
resetMigrationTestDb();
|
|
78
74
|
});
|
|
79
75
|
|
|
@@ -10,8 +10,6 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
12
12
|
|
|
13
|
-
const originalBunTest = process.env.BUN_TEST;
|
|
14
|
-
|
|
15
13
|
import { getSqliteFrom } from "../persistence/db-connection.js";
|
|
16
14
|
import { migrateLlmRequestLogProvider } from "../persistence/migrations/184-llm-request-log-provider.js";
|
|
17
15
|
import * as schema from "../persistence/schema/index.js";
|
|
@@ -48,7 +46,6 @@ function bootstrapPreProviderLlmRequestLogs(raw: Database): void {
|
|
|
48
46
|
|
|
49
47
|
describe("llm_request_logs provider migration", () => {
|
|
50
48
|
beforeEach(() => {
|
|
51
|
-
process.env.BUN_TEST = "0";
|
|
52
49
|
resetDbForTesting();
|
|
53
50
|
});
|
|
54
51
|
|
|
@@ -57,11 +54,6 @@ describe("llm_request_logs provider migration", () => {
|
|
|
57
54
|
});
|
|
58
55
|
|
|
59
56
|
afterAll(() => {
|
|
60
|
-
if (originalBunTest === undefined) {
|
|
61
|
-
delete process.env.BUN_TEST;
|
|
62
|
-
} else {
|
|
63
|
-
process.env.BUN_TEST = originalBunTest;
|
|
64
|
-
}
|
|
65
57
|
resetDbForTesting();
|
|
66
58
|
});
|
|
67
59
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createGuardianGatewaySim } from "./guardian-gateway-sim.js";
|
|
4
|
+
|
|
5
|
+
import "./test-preload.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pins the simulator to the gateway's authorization-lifecycle contract.
|
|
9
|
+
* Fifteen assistant suites prove behavior against this sim, so a sim whose
|
|
10
|
+
* semantics drift from the gateway's lets them pass against a production
|
|
11
|
+
* shape that does not exist.
|
|
12
|
+
*/
|
|
13
|
+
describe("guardian gateway sim: boot expiry contract", () => {
|
|
14
|
+
test("expires only interaction-bound kinds, never persistent kinds", async () => {
|
|
15
|
+
const sim = createGuardianGatewaySim();
|
|
16
|
+
const past = Date.now() - 60_000;
|
|
17
|
+
sim.seedRequest({ id: "ta", kind: "tool_approval", status: "pending" });
|
|
18
|
+
sim.seedRequest({
|
|
19
|
+
id: "pq",
|
|
20
|
+
kind: "pending_question",
|
|
21
|
+
status: "pending",
|
|
22
|
+
});
|
|
23
|
+
// Past-deadline persistent rows stay pending for the sweep, which owns
|
|
24
|
+
// the card-withdrawal and requester-notice fan-out; boot must not
|
|
25
|
+
// pre-empt it (the gateway contract this sim mirrors).
|
|
26
|
+
sim.seedRequest({
|
|
27
|
+
id: "ar",
|
|
28
|
+
kind: "access_request",
|
|
29
|
+
status: "pending",
|
|
30
|
+
expiresAt: past,
|
|
31
|
+
});
|
|
32
|
+
sim.seedRequest({
|
|
33
|
+
id: "tg",
|
|
34
|
+
kind: "tool_grant_request",
|
|
35
|
+
status: "pending",
|
|
36
|
+
expiresAt: past,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const expired = await sim.module.expireInteractionBoundGuardianRequests();
|
|
40
|
+
|
|
41
|
+
expect(expired).toBe(2);
|
|
42
|
+
expect(sim.getRequest("ta")?.status).toBe("expired");
|
|
43
|
+
expect(sim.getRequest("pq")?.status).toBe("expired");
|
|
44
|
+
expect(sim.getRequest("ar")?.status).toBe("pending");
|
|
45
|
+
expect(sim.getRequest("tg")?.status).toBe("pending");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -364,9 +364,13 @@ export function createGuardianGatewaySim() {
|
|
|
364
364
|
if (row.status !== "pending") {
|
|
365
365
|
continue;
|
|
366
366
|
}
|
|
367
|
+
// Mirrors the gateway contract: only the kinds that die with the
|
|
368
|
+
// daemon's in-memory pendingInteractions map expire at boot.
|
|
369
|
+
// Persistent kinds stay pending, whatever their deadline; their
|
|
370
|
+
// expiry belongs to the sweep, which owns the fan-out.
|
|
367
371
|
const interactionBound =
|
|
368
372
|
row.kind === "tool_approval" || row.kind === "pending_question";
|
|
369
|
-
if (interactionBound
|
|
373
|
+
if (interactionBound) {
|
|
370
374
|
row.status = "expired";
|
|
371
375
|
row.updatedAt = now;
|
|
372
376
|
expired += 1;
|
|
@@ -318,6 +318,18 @@ describe("non-member access request notification", () => {
|
|
|
318
318
|
kind: "access_request",
|
|
319
319
|
});
|
|
320
320
|
expect(pending.length).toBe(1);
|
|
321
|
+
|
|
322
|
+
// A pending row past its deadline never dedupes: it can sit pending
|
|
323
|
+
// until the sweep expires it, it is already undecidable, and absorbing
|
|
324
|
+
// the fresh attempt would silently drop the new guardian notification.
|
|
325
|
+
pending[0].expiresAt = Date.now() - 1000;
|
|
326
|
+
const req3 = buildInboundRequest({
|
|
327
|
+
externalMessageId: `msg-third-${Date.now()}`,
|
|
328
|
+
content: "Still hoping to get access.",
|
|
329
|
+
});
|
|
330
|
+
await handleChannelInbound(req3, undefined, TEST_BEARER_TOKEN);
|
|
331
|
+
|
|
332
|
+
expect(emitSignalCalls.length).toBe(2);
|
|
321
333
|
});
|
|
322
334
|
|
|
323
335
|
// A bare guardian deny (a `denied` request with no durable contact seeded)
|
|
@@ -210,9 +210,10 @@ export async function expireGuardianRequest(id: string): Promise<void> {
|
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
212
|
* Daemon-boot expiry: interaction-bound kinds die with the daemon's
|
|
213
|
-
* in-memory pendingInteractions map
|
|
214
|
-
*
|
|
215
|
-
*
|
|
213
|
+
* in-memory pendingInteractions map. Persistent kinds are never touched
|
|
214
|
+
* here, whatever their deadline; their expiry belongs to the sweep, which
|
|
215
|
+
* owns the card-withdrawal and requester-notice fan-out. Returns the
|
|
216
|
+
* expired count. Throws on any failure (fail-closed).
|
|
216
217
|
*/
|
|
217
218
|
export async function expireInteractionBoundGuardianRequests(): Promise<number> {
|
|
218
219
|
const response = await callGateway(
|
|
@@ -536,9 +536,7 @@ describe("--json output", () => {
|
|
|
536
536
|
});
|
|
537
537
|
|
|
538
538
|
describe("formatBrowserStatusLines", () => {
|
|
539
|
-
test("prints userActions
|
|
540
|
-
const installUrl =
|
|
541
|
-
"https://chromewebstore.google.com/detail/vellum-assistant-browser/hphbdmpffeigpcdjkckleobjmhhokpne";
|
|
539
|
+
test("prints userActions for a disconnected extension", () => {
|
|
542
540
|
const lines = formatBrowserStatusLines(
|
|
543
541
|
JSON.stringify({
|
|
544
542
|
requestedMode: "auto",
|
|
@@ -552,8 +550,8 @@ describe("formatBrowserStatusLines", () => {
|
|
|
552
550
|
summary:
|
|
553
551
|
"Extension mode is unavailable: no Chrome Extension is connected.",
|
|
554
552
|
userActions: [
|
|
555
|
-
|
|
556
|
-
"
|
|
553
|
+
"Install the Vellum Assistant Chrome extension from the Chrome Web Store: https://chromewebstore.google.com/detail/vellum-assistant-browser/hphbdmpffeigpcdjkckleobjmhhokpne",
|
|
554
|
+
"Tell the user to make sure a browser is open with the Vellum Chrome extension on.",
|
|
557
555
|
],
|
|
558
556
|
},
|
|
559
557
|
],
|
|
@@ -564,8 +562,12 @@ describe("formatBrowserStatusLines", () => {
|
|
|
564
562
|
const rendered = lines!.join("\n");
|
|
565
563
|
expect(rendered).toContain("Requested mode: auto");
|
|
566
564
|
expect(rendered).toContain("✗ extension (auto-candidate)");
|
|
567
|
-
expect(rendered).toContain(
|
|
568
|
-
|
|
565
|
+
expect(rendered).toContain(
|
|
566
|
+
"Install the Vellum Assistant Chrome extension from the Chrome Web Store:",
|
|
567
|
+
);
|
|
568
|
+
expect(rendered).toContain(
|
|
569
|
+
"Tell the user to make sure a browser is open with the Vellum Chrome extension on.",
|
|
570
|
+
);
|
|
569
571
|
});
|
|
570
572
|
|
|
571
573
|
test("omits userActions when the mode is ready", () => {
|
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
* Enumerates all active OAuth connections and validates each one for:
|
|
5
5
|
* - Token presence in secure storage
|
|
6
6
|
* - Token expiry (expired or expiring within the warning window)
|
|
7
|
-
* - Scope coverage (grantedScopes vs
|
|
7
|
+
* - Scope coverage (grantedScopes vs the request that produced the token)
|
|
8
8
|
* - Liveness ping (for providers with a pingUrl)
|
|
9
9
|
*
|
|
10
|
+
* Scope coverage asks whether a connection carries the scopes the provider
|
|
11
|
+
* currently requests for the token it stores, which is how a newly required
|
|
12
|
+
* scope reaches an existing install and prompts a re-authorization. It applies
|
|
13
|
+
* only where an authorization makes a request. A manual-token provider (a bot
|
|
14
|
+
* token pasted in, e.g. `slack_channel`) has no request and carries empty
|
|
15
|
+
* scope lists here; it is still checked for token presence and liveness. What
|
|
16
|
+
* its token actually carries is read live from the `x-oauth-scopes` response
|
|
17
|
+
* header by `runtime/channel-readiness-service.ts`, which is the only source
|
|
18
|
+
* of truth available for a credential this service never negotiated.
|
|
19
|
+
*
|
|
10
20
|
* Designed to run during the heartbeat cycle. The BYO liveness ping is
|
|
11
21
|
* routed through `withValidToken`, so a stale-but-refreshable access token
|
|
12
22
|
* is refreshed transparently before the ping fires — this prevents the
|
|
@@ -24,7 +34,10 @@ import {
|
|
|
24
34
|
type OAuthConnectionRow,
|
|
25
35
|
type OAuthProviderRow,
|
|
26
36
|
} from "../oauth/oauth-store.js";
|
|
27
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
expectedScopesForStoredToken,
|
|
39
|
+
scopeDifference,
|
|
40
|
+
} from "../oauth/scope-utils.js";
|
|
28
41
|
import {
|
|
29
42
|
TokenExpiredError,
|
|
30
43
|
withValidToken,
|
|
@@ -182,6 +195,8 @@ interface CheckConnectionOpts {
|
|
|
182
195
|
hasRefreshToken: boolean;
|
|
183
196
|
grantedScopesRaw: string;
|
|
184
197
|
defaultScopesRaw: string;
|
|
198
|
+
authorizeParamsRaw: string | null;
|
|
199
|
+
scopeSeparator: string | null;
|
|
185
200
|
pingUrl: string | null;
|
|
186
201
|
pingMethod: string | null;
|
|
187
202
|
pingHeaders: string | null;
|
|
@@ -199,6 +214,8 @@ async function checkConnection(
|
|
|
199
214
|
hasRefreshToken,
|
|
200
215
|
grantedScopesRaw,
|
|
201
216
|
defaultScopesRaw,
|
|
217
|
+
authorizeParamsRaw,
|
|
218
|
+
scopeSeparator,
|
|
202
219
|
pingUrl,
|
|
203
220
|
pingMethod,
|
|
204
221
|
pingHeaders,
|
|
@@ -268,11 +285,21 @@ async function checkConnection(
|
|
|
268
285
|
// Has refresh token — not an issue, auto-refresh will handle it
|
|
269
286
|
}
|
|
270
287
|
|
|
271
|
-
// 3. Check scope coverage
|
|
288
|
+
// 3. Check scope coverage, against the request that produced the stored
|
|
289
|
+
// token rather than the provider's bot-side `scope` parameter. A provider
|
|
290
|
+
// asking for user scopes as well stores the user token and records that
|
|
291
|
+
// token's grant, so the bot request names scopes that grant can never hold.
|
|
272
292
|
const grantedScopes = safeJsonParse<string[]>(grantedScopesRaw, []);
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
293
|
+
const expectedScopes = expectedScopesForStoredToken(
|
|
294
|
+
safeJsonParse<string[]>(defaultScopesRaw, []),
|
|
295
|
+
safeJsonParse<Record<string, string> | undefined>(
|
|
296
|
+
authorizeParamsRaw,
|
|
297
|
+
undefined,
|
|
298
|
+
),
|
|
299
|
+
scopeSeparator ?? undefined,
|
|
300
|
+
);
|
|
301
|
+
if (expectedScopes.length > 0 && grantedScopes.length > 0) {
|
|
302
|
+
const missing = scopeDifference(expectedScopes, grantedScopes);
|
|
276
303
|
if (missing.length > 0) {
|
|
277
304
|
return {
|
|
278
305
|
...base,
|
|
@@ -637,6 +664,8 @@ export async function checkAllCredentials(): Promise<CredentialHealthReport> {
|
|
|
637
664
|
hasRefreshToken: !!conn.hasRefreshToken,
|
|
638
665
|
grantedScopesRaw: conn.grantedScopes,
|
|
639
666
|
defaultScopesRaw: providerRow.defaultScopes,
|
|
667
|
+
authorizeParamsRaw: providerRow.authorizeParams,
|
|
668
|
+
scopeSeparator: providerRow.scopeSeparator,
|
|
640
669
|
pingUrl: providerRow.pingUrl,
|
|
641
670
|
pingMethod: providerRow.pingMethod,
|
|
642
671
|
pingHeaders: providerRow.pingHeaders,
|
|
@@ -750,6 +779,8 @@ export async function checkCredentialForProvider(
|
|
|
750
779
|
hasRefreshToken: !!conn.hasRefreshToken,
|
|
751
780
|
grantedScopesRaw: conn.grantedScopes,
|
|
752
781
|
defaultScopesRaw: providerRow.defaultScopes,
|
|
782
|
+
authorizeParamsRaw: providerRow.authorizeParams,
|
|
783
|
+
scopeSeparator: providerRow.scopeSeparator,
|
|
753
784
|
pingUrl: providerRow.pingUrl,
|
|
754
785
|
pingMethod: providerRow.pingMethod,
|
|
755
786
|
pingHeaders: providerRow.pingHeaders,
|