@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.
Files changed (35) hide show
  1. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  4. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  6. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  7. package/openapi.yaml +5 -5
  8. package/package.json +1 -1
  9. package/src/__tests__/credential-health-service.test.ts +45 -0
  10. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
  11. package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
  12. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
  13. package/src/__tests__/guardian-gateway-sim.test.ts +47 -0
  14. package/src/__tests__/guardian-gateway-sim.ts +5 -1
  15. package/src/__tests__/non-member-access-request.test.ts +12 -0
  16. package/src/channels/gateway-guardian-requests.ts +4 -3
  17. package/src/cli/commands/__tests__/browser.test.ts +9 -7
  18. package/src/credential-health/credential-health-service.ts +37 -6
  19. package/src/daemon/lifecycle.ts +10 -13
  20. package/src/oauth/scope-utils.ts +48 -0
  21. package/src/persistence/db-connection.ts +3 -21
  22. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
  23. package/src/persistence/embeddings/embedding-local.ts +7 -112
  24. package/src/runtime/access-request-helper.ts +15 -7
  25. package/src/runtime/routes/integrations/slack/channel.ts +39 -18
  26. package/src/runtime/tool-grant-request-helper.ts +4 -0
  27. package/src/security/oauth2.ts +2 -11
  28. package/src/tools/browser/__tests__/browser-status.test.ts +9 -7
  29. package/src/tools/browser/browser-execution.ts +4 -40
  30. package/src/tools/browser/browser-status-constants.ts +0 -6
  31. package/src/util/__tests__/worker-process-command.test.ts +246 -1
  32. package/src/util/platform.ts +13 -81
  33. package/src/util/process-table.ts +14 -5
  34. package/src/util/worker-ownership.ts +126 -0
  35. package/src/util/worker-process.ts +369 -20
@@ -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.
@@ -7,7 +7,8 @@
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./seeds": "./src/seeds.ts",
10
- "./shell": "./src/shell.ts"
10
+ "./shell": "./src/shell.ts",
11
+ "./test-path-guard": "./src/test-path-guard.ts"
11
12
  },
12
13
  "scripts": {
13
14
  "typecheck": "bunx tsc --noEmit",
@@ -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
- required:
16262
- - botToken
16263
- - appToken
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.7-dev.202608281503.cbafd6c",
3
+ "version": "0.11.7-dev.202608282003.8ce5421",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 || isGuardianRequestExpired(row, now)) {
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, plus persistent kinds already past
214
- * `expiresAt`. Returns the expired count. Throws on any failure
215
- * (fail-closed).
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 including the Chrome Web Store install URL", () => {
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
- `Install the Vellum Assistant Chrome extension from the Chrome Web Store: ${installUrl}`,
556
- "Open the extension and pair with your assistant.",
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(installUrl);
568
- expect(rendered).toContain("Open the extension and pair with your assistant.");
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 provider defaultScopes)
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 { scopeDifference } from "../oauth/scope-utils.js";
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 defaultScopes = safeJsonParse<string[]>(defaultScopesRaw, []);
274
- if (defaultScopes.length > 0 && grantedScopes.length > 0) {
275
- const missing = scopeDifference(defaultScopes, grantedScopes);
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,