@vellumai/assistant 0.11.4-dev.202608201011.f81ab8d → 0.11.4-dev.202608201321.78202a0

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/openapi.yaml CHANGED
@@ -4651,6 +4651,47 @@ paths:
4651
4651
  required:
4652
4652
  - recorded
4653
4653
  additionalProperties: false
4654
+ /v1/clients/web-presence:
4655
+ post:
4656
+ operationId: clients_webpresence_post
4657
+ summary: Report web tab visibility and focused conversation
4658
+ description:
4659
+ Record the visibility and focused conversation reported by the web client identified by the
4660
+ X-Vellum-Client-Id header.
4661
+ tags:
4662
+ - clients
4663
+ requestBody:
4664
+ required: true
4665
+ content:
4666
+ application/json:
4667
+ schema:
4668
+ type: object
4669
+ properties:
4670
+ visible:
4671
+ type: boolean
4672
+ description: Whether the reporting tab is currently visible.
4673
+ focusedConversationId:
4674
+ anyOf:
4675
+ - type: string
4676
+ - type: "null"
4677
+ description: The conversation currently focused (chat composer on screen) in the tab, or null.
4678
+ required:
4679
+ - visible
4680
+ - focusedConversationId
4681
+ responses:
4682
+ "200":
4683
+ description: Successful response
4684
+ content:
4685
+ application/json:
4686
+ schema:
4687
+ type: object
4688
+ properties:
4689
+ recorded:
4690
+ type: boolean
4691
+ description: Whether a connected client matched the reporting clientId.
4692
+ required:
4693
+ - recorded
4694
+ additionalProperties: false
4654
4695
  /v1/config:
4655
4696
  get:
4656
4697
  operationId: config_get
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608201011.f81ab8d",
3
+ "version": "0.11.4-dev.202608201321.78202a0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -201,6 +201,22 @@ describe("client OS per-row evidence marker", () => {
201
201
  expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(true);
202
202
  });
203
203
 
204
+ // Mirrors the Mac-originated case above: a plain browser tab reports
205
+ // `client.os: "web"`, while the Electron desktop renderer resolves its own
206
+ // host OS first and persists `macos`/`windows` instead.
207
+ test("marks an OS this row's own transport reported (web)", async () => {
208
+ const ctx = createWebTurnContext("web");
209
+ await persistQueuedMessageBody(ctx, {
210
+ content: "hello",
211
+ requestId: "req-transport-os-web",
212
+ requestClientOs: "web",
213
+ });
214
+
215
+ expect(lastUserMetadata().client).toEqual({ os: "web" });
216
+ expect(lastUserMetadata().clientOsFromRequest).toBe(true);
217
+ expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
218
+ });
219
+
204
220
  test("marks an OS this row's own request headers reported", async () => {
205
221
  const ctx = createWebTurnContext(undefined);
206
222
  await persistQueuedMessageBody(ctx, {
@@ -44,8 +44,24 @@ import { setSecureKeyAsync } from "../security/secure-keys.js";
44
44
  import { CredentialBroker } from "../tools/credentials/broker.js";
45
45
  import {
46
46
  _setMetadataPath,
47
+ type CredentialMetadata,
47
48
  upsertCredentialMetadata,
48
49
  } from "../tools/credentials/metadata-store.js";
50
+ import { serverUseDenialReason } from "../tools/credentials/tool-policy.js";
51
+
52
+ /** The shared policy's verdict for a state the broker must also deny. */
53
+ function sharedDenialReason(
54
+ metadata: CredentialMetadata,
55
+ toolName: string,
56
+ service: string,
57
+ field: string,
58
+ ): string {
59
+ const reason = serverUseDenialReason(metadata, toolName, service, field);
60
+ if (!reason) {
61
+ throw new Error("expected a denial reason from the shared policy");
62
+ }
63
+ return reason;
64
+ }
49
65
 
50
66
  // ---------------------------------------------------------------------------
51
67
  // Tests — serverUse (publish_page / unpublish_page regression)
@@ -518,9 +534,10 @@ describe("CredentialBroker.serverUseById", () => {
518
534
  if (result.success) {
519
535
  throw new Error("expected denial");
520
536
  }
521
- expect(result.reason).toContain("not allowed");
522
- expect(result.reason).toContain("unauthorized_tool");
523
- expect(result.reason).toContain("media_proxy");
537
+ // The broker passes the shared server-use policy's verdict through verbatim.
538
+ expect(result.reason).toBe(
539
+ sharedDenialReason(meta, "unauthorized_tool", "fal", "api_key"),
540
+ );
524
541
  });
525
542
 
526
543
  test("returns not found for unknown credential ID", async () => {
@@ -553,8 +570,10 @@ describe("CredentialBroker.serverUseById", () => {
553
570
  if (result.success) {
554
571
  throw new Error("expected denial");
555
572
  }
556
- expect(result.reason).toContain("domain restrictions");
557
- expect(result.reason).toContain("cannot be used server-side");
573
+ // The broker passes the shared server-use policy's verdict through verbatim.
574
+ expect(result.reason).toBe(
575
+ sharedDenialReason(meta, "media_proxy", "github", "oauth_token"),
576
+ );
558
577
  });
559
578
 
560
579
  test("returns empty injection templates when credential has none", async () => {
@@ -32,7 +32,10 @@ import {
32
32
  populateCacheAtBoot,
33
33
  resetPluginCacheForTests,
34
34
  } from "../plugins/mtime-cache.js";
35
- import { PRESERVED_ENTRIES } from "../plugins/plugin-tree-walk.js";
35
+ import {
36
+ PRESERVED_ENTRIES,
37
+ USER_STATE_ENTRIES,
38
+ } from "../plugins/plugin-tree-walk.js";
36
39
 
37
40
  const ROOT = join(
38
41
  tmpdir(),
@@ -87,6 +90,10 @@ describe("PRESERVED_ENTRIES", () => {
87
90
  expect(PRESERVED_ENTRIES).toContain("data");
88
91
  expect(PRESERVED_ENTRIES).toContain(".disabled");
89
92
  });
93
+
94
+ test("user-state entries are the preserved set minus the provenance sidecar", () => {
95
+ expect([...USER_STATE_ENTRIES]).toEqual(["config.json", "data", ".disabled"]);
96
+ });
90
97
  });
91
98
 
92
99
  describe("fingerprint exclusion of preserved entries", () => {
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { isToolAllowed } from "../tools/credentials/tool-policy.js";
3
+ import type { CredentialMetadata } from "../tools/credentials/metadata-store.js";
4
+ import {
5
+ BROWSER_FILL_CAPABILITY,
6
+ isToolAllowed,
7
+ serverUseDenialReason,
8
+ } from "../tools/credentials/tool-policy.js";
4
9
 
5
10
  describe("isToolAllowed", () => {
6
11
  // ── Allow cases ─────────────────────────────────────────────────────
@@ -72,3 +77,94 @@ describe("isToolAllowed", () => {
72
77
  ).toBe(false);
73
78
  });
74
79
  });
80
+
81
+ describe("serverUseDenialReason", () => {
82
+ function metadata(
83
+ overrides: Partial<CredentialMetadata> = {},
84
+ ): CredentialMetadata {
85
+ return {
86
+ credentialId: "cred-1",
87
+ service: "acp",
88
+ field: "claude_oauth_token",
89
+ allowedTools: ["acp_spawn"],
90
+ allowedDomains: [],
91
+ createdAt: 0,
92
+ updatedAt: 0,
93
+ ...overrides,
94
+ };
95
+ }
96
+
97
+ test("denies when metadata is missing", () => {
98
+ expect(
99
+ serverUseDenialReason(
100
+ undefined,
101
+ "acp_spawn",
102
+ "acp",
103
+ "claude_oauth_token",
104
+ ),
105
+ ).toBe("No credential found for acp/claude_oauth_token");
106
+ });
107
+
108
+ test("denies fail-closed when allowedTools is empty", () => {
109
+ const reason = serverUseDenialReason(
110
+ metadata({ allowedTools: [] }),
111
+ "acp_spawn",
112
+ "acp",
113
+ "claude_oauth_token",
114
+ );
115
+ expect(reason).toContain(
116
+ 'Tool "acp_spawn" is not allowed to use credential acp/claude_oauth_token.',
117
+ );
118
+ expect(reason).toContain("No tools are currently allowed");
119
+ });
120
+
121
+ test("denies when allowedTools omits the requesting tool", () => {
122
+ const reason = serverUseDenialReason(
123
+ metadata({ allowedTools: ["bash", "web_fetch"] }),
124
+ "acp_spawn",
125
+ "acp",
126
+ "claude_oauth_token",
127
+ );
128
+ expect(reason).toContain(
129
+ 'Tool "acp_spawn" is not allowed to use credential acp/claude_oauth_token.',
130
+ );
131
+ expect(reason).toContain("Allowed tools: bash, web_fetch.");
132
+ });
133
+
134
+ test("allows when a legacy alias canonicalizes to the requesting tool", () => {
135
+ expect(
136
+ serverUseDenialReason(
137
+ metadata({ allowedTools: ["browser_fill_credential"] }),
138
+ BROWSER_FILL_CAPABILITY,
139
+ "acp",
140
+ "claude_oauth_token",
141
+ ),
142
+ ).toBeUndefined();
143
+ });
144
+
145
+ test("denies a domain-restricted credential", () => {
146
+ expect(
147
+ serverUseDenialReason(
148
+ metadata({ allowedDomains: ["example.com", "example.org"] }),
149
+ "acp_spawn",
150
+ "acp",
151
+ "claude_oauth_token",
152
+ ),
153
+ ).toBe(
154
+ "Credential acp/claude_oauth_token has domain restrictions " +
155
+ "(example.com, example.org) and cannot be used server-side. " +
156
+ "Remove domain restrictions or use a separate credential without domain policy.",
157
+ );
158
+ });
159
+
160
+ test("allows fully-permitted metadata", () => {
161
+ expect(
162
+ serverUseDenialReason(
163
+ metadata(),
164
+ "acp_spawn",
165
+ "acp",
166
+ "claude_oauth_token",
167
+ ),
168
+ ).toBeUndefined();
169
+ });
170
+ });
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * Tests for the Claude OAuth config + capture/store helpers.
3
3
  *
4
- * The store helper reaches into secure-keys and the ACP credential policy, so
5
- * we mock both (wired BEFORE importing the module under test via dynamic
6
- * import) and assert the vault write targets `credential/acp/claude_oauth_token`
7
- * and throws when the backend rejects the write.
4
+ * The store helper reaches into secure-keys, so we mock that (wired BEFORE
5
+ * importing the module under test via dynamic import) and assert the vault
6
+ * write targets `credential/acp/claude_oauth_token` and throws when the backend
7
+ * rejects the write.
8
+ *
9
+ * The ACP credential policy is NOT mocked: `hasAcpClaudeToken` has to answer
10
+ * exactly what the spawn-time broker read would, so it is exercised against the
11
+ * real metadata store (pointed at a temp dir) and the real policy evaluation.
8
12
  */
9
13
 
10
- import { beforeEach, describe, expect, mock, test } from "bun:test";
14
+ import { randomBytes } from "node:crypto";
15
+ import { mkdirSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
19
 
12
20
  // ---------------------------------------------------------------------------
13
21
  // Mocks — wired BEFORE importing the module via dynamic import.
@@ -19,20 +27,17 @@ const setSecureKeyAsync = mock(
19
27
  async (_account: string, _value: string) => storeReturn,
20
28
  );
21
29
  const getSecureKeyAsync = mock(async (_account: string) => getReturn);
22
- const grantAcpSpawnPolicy = mock(
23
- (_field: string, _usageDescription: string) => {},
24
- );
25
- let spawnCanRead = true;
26
- const acpSpawnCanReadCredential = mock((_field: string) => spawnCanRead);
27
30
 
28
31
  mock.module("../../security/secure-keys.js", () => ({
29
32
  setSecureKeyAsync,
30
33
  getSecureKeyAsync,
31
34
  }));
32
- mock.module("../prepare-agent-env.js", () => ({
33
- grantAcpSpawnPolicy,
34
- acpSpawnCanReadCredential,
35
- }));
35
+
36
+ const { _setMetadataPath, getCredentialMetadata, upsertCredentialMetadata } =
37
+ await import("../../tools/credentials/metadata-store.js");
38
+
39
+ const { acpSpawnCredentialDenialReason } =
40
+ await import("../prepare-agent-env.js");
36
41
 
37
42
  const {
38
43
  CLAUDE_OAUTH_CONFIG,
@@ -43,14 +48,31 @@ const {
43
48
  hasAcpClaudeToken,
44
49
  } = await import("../acp-claude-oauth.js");
45
50
 
51
+ const ACP_SERVICE = "acp";
52
+ const OAUTH_FIELD = "claude_oauth_token";
53
+ const ACP_SPAWN_TOOL = "acp_spawn";
54
+
55
+ const TEST_DIR = join(
56
+ tmpdir(),
57
+ `vellum-acp-claude-oauth-test-${randomBytes(4).toString("hex")}`,
58
+ );
59
+
60
+ function oauthMetadata() {
61
+ return getCredentialMetadata(ACP_SERVICE, OAUTH_FIELD);
62
+ }
63
+
46
64
  beforeEach(() => {
65
+ mkdirSync(TEST_DIR, { recursive: true });
66
+ _setMetadataPath(join(TEST_DIR, "metadata.json"));
47
67
  storeReturn = true;
48
68
  getReturn = undefined;
49
- spawnCanRead = true;
50
69
  setSecureKeyAsync.mockClear();
51
70
  getSecureKeyAsync.mockClear();
52
- grantAcpSpawnPolicy.mockClear();
53
- acpSpawnCanReadCredential.mockClear();
71
+ });
72
+
73
+ afterEach(() => {
74
+ _setMetadataPath(null);
75
+ rmSync(TEST_DIR, { recursive: true, force: true });
54
76
  });
55
77
 
56
78
  // ---------------------------------------------------------------------------
@@ -149,7 +171,7 @@ describe("parseManualClaudeCode", () => {
149
171
  // ---------------------------------------------------------------------------
150
172
 
151
173
  describe("storeAcpClaudeToken", () => {
152
- test("writes the token and force-grants the acp_spawn policy (repairs a denied policy)", async () => {
174
+ test("writes the token to the acp/claude_oauth_token vault field", async () => {
153
175
  await storeAcpClaudeToken("sk-ant-oat-token");
154
176
 
155
177
  expect(setSecureKeyAsync).toHaveBeenCalledTimes(1);
@@ -157,10 +179,25 @@ describe("storeAcpClaudeToken", () => {
157
179
  "credential/acp/claude_oauth_token",
158
180
  "sk-ant-oat-token",
159
181
  );
160
- // grant (union), not merely ensure (preserve) — so an explicit Connect
161
- // repairs a credential whose allowedTools omitted acp_spawn.
162
- expect(grantAcpSpawnPolicy).toHaveBeenCalledTimes(1);
163
- expect(grantAcpSpawnPolicy.mock.calls[0][0]).toBe("claude_oauth_token");
182
+ });
183
+
184
+ test("takes a domain-restricted credential from not-connected to connected", async () => {
185
+ // Invariant: when the vault holds a usable token whose domain policy
186
+ // makes the spawn read fail, the status check keeps the Connect card
187
+ // offered, and completing Connect repairs the policy so the retry after
188
+ // a successful sign-in succeeds. The per-shape repair details are covered
189
+ // by the repairAcpSpawnPolicy suite in prepare-agent-env.test.ts.
190
+ upsertCredentialMetadata(ACP_SERVICE, OAUTH_FIELD, {
191
+ allowedTools: [ACP_SPAWN_TOOL],
192
+ allowedDomains: ["api.anthropic.com"],
193
+ });
194
+ getReturn = "sk-ant-oat-token";
195
+ expect(await hasAcpClaudeToken()).toBe(false);
196
+
197
+ await storeAcpClaudeToken("sk-ant-oat-token");
198
+
199
+ expect(await hasAcpClaudeToken()).toBe(true);
200
+ expect(acpSpawnCredentialDenialReason(OAUTH_FIELD)).toBeUndefined();
164
201
  });
165
202
 
166
203
  test("throws when the secure store rejects the write", async () => {
@@ -169,7 +206,7 @@ describe("storeAcpClaudeToken", () => {
169
206
  await expect(storeAcpClaudeToken("sk-ant-oat-token")).rejects.toThrow(
170
207
  /Failed to store/,
171
208
  );
172
- expect(grantAcpSpawnPolicy).not.toHaveBeenCalled();
209
+ expect(oauthMetadata()).toBeUndefined();
173
210
  });
174
211
  });
175
212
 
@@ -202,13 +239,44 @@ describe("hasAcpClaudeToken", () => {
202
239
  expect(await hasAcpClaudeToken()).toBe(false);
203
240
  });
204
241
 
205
- test("reports false when the spawn policy can't read the token (denied allowedTools)", async () => {
206
- // A valid OAuth token is stored, but an explicit `allowedTools` that omits
207
- // `acp_spawn` means the broker denies the spawn read. Reporting "connected"
208
- // would self-dismiss the card and trap the user in a missing-token loop, so
209
- // it stays not-connected to keep the repair CTA visible.
242
+ test("reports true for metadata with an empty allowedTools (the spawn would grant acp_spawn)", async () => {
243
+ // One allow-state case: the full policy matrix is guarded by the parity
244
+ // suite in prepare-agent-env.test.ts and the tool-policy unit tests.
245
+ upsertCredentialMetadata(ACP_SERVICE, OAUTH_FIELD, { allowedTools: [] });
210
246
  getReturn = "sk-ant-oat-token";
211
- spawnCanRead = false;
247
+
248
+ expect(await hasAcpClaudeToken()).toBe(true);
249
+ });
250
+
251
+ test("reports false for a domain-restricted credential the broker refuses server-side", async () => {
252
+ // acp_spawn is allowed, but a non-empty allowedDomains scopes the credential
253
+ // to browser fills, so every spawn read is denied. The status check has to
254
+ // see that too, otherwise the Connect card self-dismisses while acp_spawn
255
+ // keeps failing with the missing-token marker.
256
+ upsertCredentialMetadata(ACP_SERVICE, OAUTH_FIELD, {
257
+ allowedTools: [ACP_SPAWN_TOOL],
258
+ allowedDomains: ["api.anthropic.com"],
259
+ });
260
+ getReturn = "sk-ant-oat-token";
261
+
212
262
  expect(await hasAcpClaudeToken()).toBe(false);
213
263
  });
264
+
265
+ test("writes nothing to the metadata store (the status route is a GET)", async () => {
266
+ upsertCredentialMetadata(ACP_SERVICE, OAUTH_FIELD, { allowedTools: [] });
267
+ getReturn = "sk-ant-oat-token";
268
+ const before = oauthMetadata();
269
+
270
+ await hasAcpClaudeToken();
271
+
272
+ expect(oauthMetadata()).toEqual(before);
273
+ });
274
+
275
+ test("creates no metadata when there is none to begin with", async () => {
276
+ getReturn = "sk-ant-oat-token";
277
+
278
+ await hasAcpClaudeToken();
279
+
280
+ expect(oauthMetadata()).toBeUndefined();
281
+ });
214
282
  });