@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
@@ -461,19 +461,16 @@ export async function runDaemon(): Promise<void> {
461
461
  );
462
462
  }
463
463
 
464
- // Expire stale pending guardian requests left over from before this
465
- // process started. Daemon-keyed by design: interaction-bound kinds die
466
- // with THIS process's in-memory pendingInteractions map, so the daemon
467
- // triggers the gateway op at its own boot — the gateway never runs it
468
- // on its own restart. Two categories are cleaned up:
469
- //
470
- // 1. Interaction-bound kinds (tool_approval, pending_question) — their
471
- // in-memory pending-interaction session references are gone, so they
472
- // can never be completed.
473
- // 2. Any pending request whose expiresAt has already passed — persistent
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
@@ -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, realpathSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, join, resolve, sep } from "node:path";
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
- LocalEmbeddingBackend,
22
- } from "../embedding-local.js";
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
- pid1OwnsEmbedWorkers(),
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
- const existingPending = await listGuardianRequestsOrEmpty({
232
- status: "pending",
233
- requesterExternalUserId: actorExternalId,
234
- sourceChannel,
235
- kind: "access_request",
236
- sourceConversationId: conversationId,
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 } = body as {
36
- botToken?: string;
37
- appToken?: string;
38
- userToken?: string;
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 as { threadMode?: string };
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: z.object({
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: z.object({
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
  );
@@ -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
- CHROME_WEB_STORE_INSTALL_URL,
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.join("\n")).toContain(
219
- CHROME_WEB_STORE_INSTALL_URL,
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.join("\n")).toContain(
245
- CHROME_WEB_STORE_INSTALL_URL,
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 extensionSetupActions(): string[] {
2490
+ function extensionConnectionActions(): string[] {
2492
2491
  return [
2493
2492
  CHROME_EXTENSION_INSTALL_HINT,
2494
- "Open the extension and pair with your assistant.",
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(...extensionSetupActions());
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: extensionSetupActions(),
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";