@vellumai/assistant 0.11.2-dev.202608051734.355dff7 → 0.11.2-dev.202608051933.deffc0f
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/ARCHITECTURE.md +4 -2
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/openapi.yaml +22 -0
- package/package.json +1 -1
- package/src/__tests__/agent-loop-override-profile.test.ts +38 -48
- package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -10
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -0
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +61 -0
- package/src/__tests__/cu-unified-flow.test.ts +2 -6
- package/src/__tests__/file-ops-service.test.ts +75 -4
- package/src/__tests__/host-bash-proxy.test.ts +10 -5
- package/src/__tests__/host-browser-proxy.test.ts +2 -2
- package/src/__tests__/host-cu-proxy.test.ts +3 -7
- package/src/__tests__/host-file-proxy.test.ts +3 -7
- package/src/__tests__/host-file-read-tool.test.ts +5 -1
- package/src/__tests__/host-transfer-proxy-targeted.test.ts +6 -4
- package/src/__tests__/llm-resolver-override-or-default.test.ts +141 -0
- package/src/__tests__/media-stream-server-integration.test.ts +36 -0
- package/src/__tests__/stt-language-catalog-parity.test.ts +38 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -0
- package/src/__tests__/subagent-tools.test.ts +48 -92
- package/src/__tests__/tool-result-spool.test.ts +74 -18
- package/src/agent/loop.ts +6 -3
- package/src/calls/media-stream-server.ts +9 -1
- package/src/config/bundled-skills/settings/TOOLS.json +2 -2
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +1 -0
- package/src/config/bundled-skills/subagent/SKILL.md +3 -3
- package/src/config/bundled-skills/subagent/TOOLS.json +1 -1
- package/src/config/feature-flag-registry.json +0 -16
- package/src/config/llm-resolver.ts +41 -12
- package/src/config/schemas/stt.ts +10 -4
- package/src/context/post-turn-tool-result-truncation.ts +32 -16
- package/src/context/tool-result-spool.ts +32 -18
- package/src/daemon/conversation-tool-setup.ts +1 -1
- package/src/persistence/conversation-attention-store.ts +30 -10
- package/src/persistence/conversation-queries.ts +74 -1
- package/src/platform/managed-speech.test.ts +20 -0
- package/src/platform/managed-speech.ts +9 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +164 -6
- package/src/providers/speech-to-text/deepgram.ts +1 -0
- package/src/providers/speech-to-text/resolve.ts +80 -10
- package/src/providers/speech-to-text/vellum-managed.ts +7 -1
- package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +3 -1
- package/src/runtime/auth/__tests__/guard-tests.test.ts +2 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +20 -0
- package/src/runtime/auth/same-actor.ts +38 -7
- package/src/runtime/auth/scopes.ts +12 -0
- package/src/runtime/auth/types.ts +2 -0
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +279 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +14 -10
- package/src/runtime/routes/conversation-list-routes.ts +25 -0
- package/src/runtime/routes/conversation-query-routes.ts +0 -50
- package/src/stt/daemon-batch-transcriber.ts +8 -6
- package/src/tools/filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/read.ts +14 -6
- package/src/tools/shared/filesystem/file-ops-service.ts +36 -3
- package/src/tools/subagent/spawn.ts +26 -78
- package/src/tools/types.ts +5 -3
package/ARCHITECTURE.md
CHANGED
|
@@ -67,8 +67,10 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
|
|
|
67
67
|
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
|
68
68
|
| `actor_client_v1` | `chat.{read,write}`, `approval.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `calls.{read,write}`, `feature_flags.{read,write}` | Desktop, CLI clients |
|
|
69
69
|
| `gateway_ingress_v1` | `ingress.write`, `internal.write` | Gateway channel inbound + webhook forwarding |
|
|
70
|
-
| `gateway_service_v1` | `
|
|
71
|
-
| `
|
|
70
|
+
| `gateway_service_v1` | `chat.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `internal.write` | Gateway service-to-daemon calls |
|
|
71
|
+
| `local_v1` | `local.all` | Local (loopback) conversation sessions |
|
|
72
|
+
| `speech_relay_v1` | `speech.relay` | Daemon dial of the gateway speech relay only |
|
|
73
|
+
| `ui_page_v1` | `settings.read` | Served UI pages |
|
|
72
74
|
|
|
73
75
|
**Identity lifecycle:**
|
|
74
76
|
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts
CHANGED
|
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
|
|
20
20
|
return normalized.length > 0 ? normalized : undefined;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Application close code the gateway's velay bridge sends to proxied
|
|
25
|
+
* WebSockets when the tunnel itself is lost (velay disconnect, gateway
|
|
26
|
+
* shutdown). A dedicated code because the natural 1001 (going away) cannot
|
|
27
|
+
* be sent through the JS `close()` API (the bridge would remap it to a
|
|
28
|
+
* misleading 4001), and Bun's WebSocket client drops close reasons, so the
|
|
29
|
+
* code is the only signal that survives the relay to the daemon.
|
|
30
|
+
*/
|
|
31
|
+
export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
|
|
32
|
+
|
|
23
33
|
export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
|
|
24
34
|
if (typeof value !== "string") return undefined;
|
|
25
35
|
const trimmed = value.trim();
|
|
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
|
|
20
20
|
return normalized.length > 0 ? normalized : undefined;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Application close code the gateway's velay bridge sends to proxied
|
|
25
|
+
* WebSockets when the tunnel itself is lost (velay disconnect, gateway
|
|
26
|
+
* shutdown). A dedicated code because the natural 1001 (going away) cannot
|
|
27
|
+
* be sent through the JS `close()` API (the bridge would remap it to a
|
|
28
|
+
* misleading 4001), and Bun's WebSocket client drops close reasons, so the
|
|
29
|
+
* code is the only signal that survives the relay to the daemon.
|
|
30
|
+
*/
|
|
31
|
+
export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
|
|
32
|
+
|
|
23
33
|
export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
|
|
24
34
|
if (typeof value !== "string") return undefined;
|
|
25
35
|
const trimmed = value.trim();
|
|
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
|
|
20
20
|
return normalized.length > 0 ? normalized : undefined;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Application close code the gateway's velay bridge sends to proxied
|
|
25
|
+
* WebSockets when the tunnel itself is lost (velay disconnect, gateway
|
|
26
|
+
* shutdown). A dedicated code because the natural 1001 (going away) cannot
|
|
27
|
+
* be sent through the JS `close()` API (the bridge would remap it to a
|
|
28
|
+
* misleading 4001), and Bun's WebSocket client drops close reasons, so the
|
|
29
|
+
* code is the only signal that survives the relay to the daemon.
|
|
30
|
+
*/
|
|
31
|
+
export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
|
|
32
|
+
|
|
23
33
|
export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
|
|
24
34
|
if (typeof value !== "string") return undefined;
|
|
25
35
|
const trimmed = value.trim();
|
package/openapi.yaml
CHANGED
|
@@ -9565,6 +9565,28 @@ paths:
|
|
|
9565
9565
|
required:
|
|
9566
9566
|
- ok
|
|
9567
9567
|
additionalProperties: false
|
|
9568
|
+
/v1/conversations/unread-count:
|
|
9569
|
+
get:
|
|
9570
|
+
operationId: conversations_unreadcount_get
|
|
9571
|
+
summary: Unread conversation count
|
|
9572
|
+
description:
|
|
9573
|
+
"Count of foreground conversations whose latest assistant message is unseen. Matches the sidebar's unread
|
|
9574
|
+
indicators: archived rows and non-surfaced background/scheduled rows are excluded."
|
|
9575
|
+
tags:
|
|
9576
|
+
- conversations
|
|
9577
|
+
responses:
|
|
9578
|
+
"200":
|
|
9579
|
+
description: Successful response
|
|
9580
|
+
content:
|
|
9581
|
+
application/json:
|
|
9582
|
+
schema:
|
|
9583
|
+
type: object
|
|
9584
|
+
properties:
|
|
9585
|
+
count:
|
|
9586
|
+
type: number
|
|
9587
|
+
required:
|
|
9588
|
+
- count
|
|
9589
|
+
additionalProperties: false
|
|
9568
9590
|
/v1/conversations/wake:
|
|
9569
9591
|
post:
|
|
9570
9592
|
operationId: conversations_wake_post
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Verifies that the per-turn `overrideProfile` plumbed into `AgentLoop.run()`
|
|
3
3
|
* surfaces on every `SendMessageOptions.config` the loop emits, and that
|
|
4
|
-
* `SubagentManager.spawn()` propagates an
|
|
5
|
-
*
|
|
4
|
+
* `SubagentManager.spawn()` propagates an `overrideProfile` set on its
|
|
5
|
+
* `SubagentConfig` into the subagent's `runAgentLoop()` call.
|
|
6
6
|
*
|
|
7
|
-
* Together these
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Together these establish where a pinned profile does and does not travel: it
|
|
8
|
+
* applies to every LLM call within the pinning conversation's own turn, and it
|
|
9
|
+
* stops at the spawn boundary. `executeSubagentSpawn` sets `overrideProfile`
|
|
10
|
+
* on the `SubagentConfig` only for a profile named explicitly on the spawn, so
|
|
11
|
+
* the propagation above carries a caller's choice rather than a parent's pin.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* With no `overrideProfile` set, the field is omitted from `providerConfig`
|
|
14
|
+
* rather than carrying `undefined`.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { describe, expect, mock, test } from "bun:test";
|
|
@@ -183,7 +184,7 @@ describe("AgentLoop.run — overrideProfile plumbing", () => {
|
|
|
183
184
|
});
|
|
184
185
|
});
|
|
185
186
|
|
|
186
|
-
// ── Subagent
|
|
187
|
+
// ── Subagent profile forwarding ──────────────────────────────────────────
|
|
187
188
|
|
|
188
189
|
// Capture the SubagentManager → Conversation handshake so we can verify the
|
|
189
190
|
// `overrideProfile` from `SubagentConfig` is forwarded into the spawned
|
|
@@ -329,7 +330,7 @@ seedLlmConfig();
|
|
|
329
330
|
|
|
330
331
|
import { SubagentManager } from "../subagent/manager.js";
|
|
331
332
|
|
|
332
|
-
describe("SubagentManager.spawn
|
|
333
|
+
describe("SubagentManager.spawn: forwards a configured overrideProfile", () => {
|
|
333
334
|
test("forwards overrideProfile from SubagentConfig into runAgentLoop", async () => {
|
|
334
335
|
capturedRunAgentLoopOptions.length = 0;
|
|
335
336
|
|
|
@@ -344,9 +345,9 @@ describe("SubagentManager.spawn — overrideProfile inheritance", () => {
|
|
|
344
345
|
() => {},
|
|
345
346
|
);
|
|
346
347
|
|
|
347
|
-
// The spawned subagent's runAgentLoop
|
|
348
|
-
//
|
|
349
|
-
// `
|
|
348
|
+
// The spawned subagent's runAgentLoop receives the `subagentSpawn`
|
|
349
|
+
// callSite and whatever `overrideProfile` the config carries, which
|
|
350
|
+
// `executeSubagentSpawn` sets only for an explicitly named profile.
|
|
350
351
|
expect(capturedRunAgentLoopOptions).toHaveLength(1);
|
|
351
352
|
const captured = capturedRunAgentLoopOptions[0];
|
|
352
353
|
expect(captured.callSite).toBe("subagentSpawn");
|
|
@@ -399,21 +400,18 @@ describe("SubagentManager.spawn — overrideProfile inheritance", () => {
|
|
|
399
400
|
});
|
|
400
401
|
});
|
|
401
402
|
|
|
402
|
-
// ── Nested subagent spawn
|
|
403
|
+
// ── Nested subagent spawn: no profile crosses the boundary ───────────────
|
|
403
404
|
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// inheritance chain breaks at the second nesting level.
|
|
405
|
+
// When a subagent's agent loop is running under a turn profile, the executor
|
|
406
|
+
// closure plumbs that value into `ToolContext.overrideProfile`. That value
|
|
407
|
+
// describes the spawning turn, so `executeSubagentSpawn` leaves it behind: a
|
|
408
|
+
// child resolves the `subagentSpawn` call site on its own, and a forwarded
|
|
409
|
+
// override would both re-price the child and file its spend as a pin nobody
|
|
410
|
+
// set. These tests cover the nesting levels where a profile could otherwise
|
|
411
|
+
// compound down a chain of children.
|
|
412
412
|
|
|
413
413
|
mock.module("../persistence/conversation-crud.js", () => ({
|
|
414
414
|
setConversationProcessingStartedAt: () => {},
|
|
415
|
-
// Always return undefined for the row read so the test fails fast unless
|
|
416
|
-
// executeSubagentSpawn reads from context.overrideProfile first.
|
|
417
415
|
getConversationOverrideProfile: () => undefined,
|
|
418
416
|
reserveMessage: mock(async () => ({ id: "msg-reserve" })),
|
|
419
417
|
}));
|
|
@@ -421,8 +419,8 @@ mock.module("../persistence/conversation-crud.js", () => ({
|
|
|
421
419
|
import { getSubagentManager } from "../subagent/index.js";
|
|
422
420
|
import { executeSubagentSpawn } from "../tools/subagent/spawn.js";
|
|
423
421
|
|
|
424
|
-
describe("executeSubagentSpawn
|
|
425
|
-
test("
|
|
422
|
+
describe("executeSubagentSpawn: the spawn boundary carries no profile", () => {
|
|
423
|
+
test("a subagent spawning a subagent does not pass its own turn profile down", async () => {
|
|
426
424
|
const manager = getSubagentManager();
|
|
427
425
|
const originalSpawn = manager.spawn.bind(manager);
|
|
428
426
|
let capturedConfig: Record<string, unknown> | undefined;
|
|
@@ -432,11 +430,11 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
|
|
|
432
430
|
};
|
|
433
431
|
|
|
434
432
|
try {
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
433
|
+
// The second-level spawn: this tool call happens inside the first
|
|
434
|
+
// subagent's tool context, where `overrideProfile` was populated by
|
|
435
|
+
// `runAgentLoopImpl` from its own turn snapshot. That snapshot describes
|
|
436
|
+
// the parent's turn, so it stops here rather than compounding down a
|
|
437
|
+
// chain of nested children.
|
|
440
438
|
const result = await executeSubagentSpawn(
|
|
441
439
|
{ label: "nested", objective: "do nested work" },
|
|
442
440
|
{
|
|
@@ -450,16 +448,13 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
|
|
|
450
448
|
|
|
451
449
|
expect(result.isError).toBe(false);
|
|
452
450
|
expect(capturedConfig).toBeDefined();
|
|
453
|
-
|
|
454
|
-
// SubagentManager.spawn forwards it into the nested subagent's
|
|
455
|
-
// runAgentLoop options — preserving inheritance across the chain.
|
|
456
|
-
expect(capturedConfig!.overrideProfile).toBe("fast");
|
|
451
|
+
expect("overrideProfile" in capturedConfig!).toBe(false);
|
|
457
452
|
} finally {
|
|
458
453
|
manager.spawn = originalSpawn;
|
|
459
454
|
}
|
|
460
455
|
});
|
|
461
456
|
|
|
462
|
-
test("
|
|
457
|
+
test("no override anywhere leaves the child on the subagentSpawn default", async () => {
|
|
463
458
|
const manager = getSubagentManager();
|
|
464
459
|
const originalSpawn = manager.spawn.bind(manager);
|
|
465
460
|
let capturedConfig: Record<string, unknown> | undefined;
|
|
@@ -481,22 +476,17 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
|
|
|
481
476
|
);
|
|
482
477
|
|
|
483
478
|
expect(capturedConfig).toBeDefined();
|
|
484
|
-
|
|
485
|
-
// the invoking call site's resolved default profile — here `mainAgent`
|
|
486
|
-
// with no active profile or pin, which resolves the catalog `balanced`
|
|
487
|
-
// default — so the subagent matches its invoker's model selection.
|
|
488
|
-
expect(capturedConfig!.overrideProfile).toBe("balanced");
|
|
479
|
+
expect("overrideProfile" in capturedConfig!).toBe(false);
|
|
489
480
|
} finally {
|
|
490
481
|
manager.spawn = originalSpawn;
|
|
491
482
|
}
|
|
492
483
|
});
|
|
493
484
|
|
|
494
|
-
test("
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// explicit
|
|
498
|
-
//
|
|
499
|
-
// resolution, overriding the user's explicit call-site choice.
|
|
485
|
+
test("a workspace active profile does not reach the child, pinned or not", async () => {
|
|
486
|
+
// The child resolves `llm.callSites.subagentSpawn` on its own, so the
|
|
487
|
+
// spawn must forward nothing either way: a forwarded override would
|
|
488
|
+
// outrank an explicit call-site pin under single-winner resolution and
|
|
489
|
+
// overturn the operator's choice.
|
|
500
490
|
const manager = getSubagentManager();
|
|
501
491
|
const originalSpawn = manager.spawn.bind(manager);
|
|
502
492
|
let capturedConfig: Record<string, unknown> | undefined;
|
|
@@ -517,7 +507,7 @@ describe("executeSubagentSpawn — nested inheritance via context.overrideProfil
|
|
|
517
507
|
{ label: "nested", objective: "do nested work" },
|
|
518
508
|
baseContext,
|
|
519
509
|
);
|
|
520
|
-
expect(capturedConfig
|
|
510
|
+
expect("overrideProfile" in capturedConfig!).toBe(false);
|
|
521
511
|
|
|
522
512
|
seedLlmConfig({
|
|
523
513
|
activeProfile: "fast",
|
|
@@ -80,16 +80,6 @@ describe("isAssistantFeatureFlagEnabled", () => {
|
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
describe("subagent flags default to disabled", () => {
|
|
84
|
-
for (const flagKey of ["subagent-profile-isolation", "subagent-loop-guard"]) {
|
|
85
|
-
test(`${flagKey} resolves to false with no override`, () => {
|
|
86
|
-
const config = {} as any;
|
|
87
|
-
|
|
88
|
-
expect(isAssistantFeatureFlagEnabled(flagKey, config)).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
83
|
describe("isAssistantFeatureFlagEnabled with skillFlagKey", () => {
|
|
94
84
|
test("resolves skill flag via canonical path", () => {
|
|
95
85
|
setOverridesForTesting({ [DECLARED_FLAG_KEY]: false });
|
|
@@ -92,6 +92,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
|
|
|
92
92
|
conversationId: "conv-test",
|
|
93
93
|
currentRequestId: "req-1",
|
|
94
94
|
workingDir: "/tmp/test",
|
|
95
|
+
getTurnActorPrincipalId: () => undefined,
|
|
95
96
|
abortController: null,
|
|
96
97
|
sendToClient: mock(() => {}),
|
|
97
98
|
pendingSurfaceActions: new Map(),
|
|
@@ -99,6 +99,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
|
|
|
99
99
|
conversationId: "conv-test",
|
|
100
100
|
currentRequestId: "req-1",
|
|
101
101
|
workingDir: "/tmp/test",
|
|
102
|
+
getTurnActorPrincipalId: () => undefined,
|
|
102
103
|
abortController: null,
|
|
103
104
|
sendToClient: mock(() => {}),
|
|
104
105
|
pendingSurfaceActions: new Map(),
|
|
@@ -402,3 +403,63 @@ describe("createToolExecutor channel-permission coordinate threading", () => {
|
|
|
402
403
|
expect(bindingLookups).toEqual([]);
|
|
403
404
|
});
|
|
404
405
|
});
|
|
406
|
+
|
|
407
|
+
describe("createToolExecutor source-actor threading", () => {
|
|
408
|
+
test("submits the turn's actor principal, not the trust context's guardian", async () => {
|
|
409
|
+
// `ToolContext.sourceActorPrincipalId` is compared against the principal a
|
|
410
|
+
// client registered with on its SSE stream, so it has to be the turn's
|
|
411
|
+
// actor. Reading `trustContext.guardianPrincipalId` instead meant any turn
|
|
412
|
+
// whose trust resolution degraded submitted nothing, and the same-actor
|
|
413
|
+
// gate rejected it as a user mismatch.
|
|
414
|
+
const { executor, calls } = makeCapturingExecutor();
|
|
415
|
+
await makeToolFn(
|
|
416
|
+
executor,
|
|
417
|
+
makeCtx({
|
|
418
|
+
getTurnActorPrincipalId: () => "actor-1",
|
|
419
|
+
currentTurnTrustContext: {
|
|
420
|
+
sourceChannel: "vellum",
|
|
421
|
+
trustClass: "guardian",
|
|
422
|
+
guardianPrincipalId: "guardian-1",
|
|
423
|
+
},
|
|
424
|
+
}),
|
|
425
|
+
)("file_read", { path: "/tmp/a" });
|
|
426
|
+
|
|
427
|
+
expect(calls[0].context.sourceActorPrincipalId).toBe("actor-1");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("carries the actor through even when trust resolved without a guardian principal", async () => {
|
|
431
|
+
// The regression: degraded trust resolution (unreachable gateway, binding
|
|
432
|
+
// drift) or a service-principal turn leaves `guardianPrincipalId` unset
|
|
433
|
+
// while the turn's actor is perfectly well known.
|
|
434
|
+
const { executor, calls } = makeCapturingExecutor();
|
|
435
|
+
await makeToolFn(
|
|
436
|
+
executor,
|
|
437
|
+
makeCtx({
|
|
438
|
+
getTurnActorPrincipalId: () => "actor-1",
|
|
439
|
+
currentTurnTrustContext: {
|
|
440
|
+
sourceChannel: "vellum",
|
|
441
|
+
trustClass: "unknown",
|
|
442
|
+
},
|
|
443
|
+
}),
|
|
444
|
+
)("file_read", { path: "/tmp/a" });
|
|
445
|
+
|
|
446
|
+
expect(calls[0].context.sourceActorPrincipalId).toBe("actor-1");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("submits nothing when the turn has no actor identity at all", async () => {
|
|
450
|
+
const { executor, calls } = makeCapturingExecutor();
|
|
451
|
+
await makeToolFn(
|
|
452
|
+
executor,
|
|
453
|
+
makeCtx({
|
|
454
|
+
getTurnActorPrincipalId: () => undefined,
|
|
455
|
+
currentTurnTrustContext: {
|
|
456
|
+
sourceChannel: "vellum",
|
|
457
|
+
trustClass: "guardian",
|
|
458
|
+
guardianPrincipalId: "guardian-1",
|
|
459
|
+
},
|
|
460
|
+
}),
|
|
461
|
+
)("file_read", { path: "/tmp/a" });
|
|
462
|
+
|
|
463
|
+
expect(calls[0].context.sourceActorPrincipalId).toBeUndefined();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
@@ -705,9 +705,7 @@ describe("surfaceProxyResolver — CU tool routing", () => {
|
|
|
705
705
|
});
|
|
706
706
|
|
|
707
707
|
expect(result.isError).toBe(true);
|
|
708
|
-
expect(result.content).toContain(
|
|
709
|
-
"Submitting actor does not match the target client's actor",
|
|
710
|
-
);
|
|
708
|
+
expect(result.content).toContain("signed in as a different user");
|
|
711
709
|
// No state mutation, no dispatch.
|
|
712
710
|
expect(proxy.stepCount).toBe(0);
|
|
713
711
|
expect(proxy.actionHistory).toHaveLength(0);
|
|
@@ -734,9 +732,7 @@ describe("surfaceProxyResolver — CU tool routing", () => {
|
|
|
734
732
|
});
|
|
735
733
|
|
|
736
734
|
expect(result.isError).toBe(true);
|
|
737
|
-
expect(result.content).toContain(
|
|
738
|
-
"Submitting actor does not match the target client's actor",
|
|
739
|
-
);
|
|
735
|
+
expect(result.content).toContain("no authenticated actor");
|
|
740
736
|
expect(sentMessages).toHaveLength(0);
|
|
741
737
|
});
|
|
742
738
|
|
|
@@ -11,6 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
DEFAULT_READ_LINE_LIMIT,
|
|
14
15
|
FileSystemOps,
|
|
15
16
|
type PathPolicy,
|
|
16
17
|
} from "../tools/shared/filesystem/file-ops-service.js";
|
|
@@ -124,10 +125,80 @@ describe("FileSystemOps.readFileSafe", () => {
|
|
|
124
125
|
if (!result.ok) {
|
|
125
126
|
return;
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
expect(
|
|
128
|
+
// Assert on the numbered lines rather than bare letters: the truncation
|
|
129
|
+
// notice is prose, so a bare `toContain("d")` matches its wording.
|
|
130
|
+
const [body] = result.value.content.split("\n\n[Truncated:");
|
|
131
|
+
expect(body).toContain(" 2 b");
|
|
132
|
+
expect(body).toContain(" 3 c");
|
|
133
|
+
expect(body).not.toContain(" 1 a");
|
|
134
|
+
expect(body).not.toContain(" 4 d");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("caps an unbounded read at the default line limit and says so", async () => {
|
|
138
|
+
const dir = makeTempDir();
|
|
139
|
+
const total = DEFAULT_READ_LINE_LIMIT + 500;
|
|
140
|
+
const lines = Array.from({ length: total }, (_, i) => `line${i + 1}`);
|
|
141
|
+
writeFileSync(join(dir, "big.txt"), lines.join("\n"));
|
|
142
|
+
const ops = new FileSystemOps(sandboxPolicyFor(dir));
|
|
143
|
+
|
|
144
|
+
const result = await ops.readFileSafe({ path: "big.txt" });
|
|
145
|
+
expect(result.ok).toBe(true);
|
|
146
|
+
if (!result.ok) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const content = result.value.content;
|
|
150
|
+
expect(content).toContain(` ${DEFAULT_READ_LINE_LIMIT} line2000`);
|
|
151
|
+
expect(content).not.toContain("line2001");
|
|
152
|
+
expect(content).toContain(
|
|
153
|
+
`[Truncated: showing through line ${DEFAULT_READ_LINE_LIMIT} of ${total}. Read on with offset=${DEFAULT_READ_LINE_LIMIT + 1}`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("an explicit limit is honored rather than replaced by the default", async () => {
|
|
158
|
+
const dir = makeTempDir();
|
|
159
|
+
const lines = Array.from(
|
|
160
|
+
{ length: DEFAULT_READ_LINE_LIMIT + 500 },
|
|
161
|
+
(_, i) => `line${i + 1}`,
|
|
162
|
+
);
|
|
163
|
+
writeFileSync(join(dir, "big.txt"), lines.join("\n"));
|
|
164
|
+
const ops = new FileSystemOps(sandboxPolicyFor(dir));
|
|
165
|
+
|
|
166
|
+
const result = await ops.readFileSafe({
|
|
167
|
+
path: "big.txt",
|
|
168
|
+
limit: DEFAULT_READ_LINE_LIMIT + 500,
|
|
169
|
+
});
|
|
170
|
+
expect(result.ok).toBe(true);
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
expect(result.value.content).toContain("line2500");
|
|
175
|
+
expect(result.value.content).not.toContain("[Truncated:");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a read that reaches the last line carries no truncation notice", async () => {
|
|
179
|
+
const dir = makeTempDir();
|
|
180
|
+
writeFileSync(join(dir, "small.txt"), "a\nb\nc");
|
|
181
|
+
const ops = new FileSystemOps(sandboxPolicyFor(dir));
|
|
182
|
+
|
|
183
|
+
const result = await ops.readFileSafe({ path: "small.txt" });
|
|
184
|
+
expect(result.ok).toBe(true);
|
|
185
|
+
if (!result.ok) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
expect(result.value.content).not.toContain("[Truncated:");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("paging past the end is not reported as truncation", async () => {
|
|
192
|
+
const dir = makeTempDir();
|
|
193
|
+
writeFileSync(join(dir, "small.txt"), "a\nb\nc");
|
|
194
|
+
const ops = new FileSystemOps(sandboxPolicyFor(dir));
|
|
195
|
+
|
|
196
|
+
const result = await ops.readFileSafe({ path: "small.txt", offset: 100 });
|
|
197
|
+
expect(result.ok).toBe(true);
|
|
198
|
+
if (!result.ok) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
expect(result.value.content).not.toContain("[Truncated:");
|
|
131
202
|
});
|
|
132
203
|
});
|
|
133
204
|
|
|
@@ -742,8 +742,11 @@ describe("HostBashProxy", () => {
|
|
|
742
742
|
});
|
|
743
743
|
|
|
744
744
|
describe("same-user binding (sourceActorPrincipalId)", () => {
|
|
745
|
-
|
|
746
|
-
|
|
745
|
+
// The proxy path names which comparison failed, so each rejection asserts
|
|
746
|
+
// its own reason — a turn with no actor, a client registered without one,
|
|
747
|
+
// and two genuinely different users are three different problems.
|
|
748
|
+
const CROSS_USER_REJECTION =
|
|
749
|
+
"Submitting actor does not match the target client's actor for this request. The target client is signed in as a different user — run `assistant clients list` to see which connected clients you own.";
|
|
747
750
|
|
|
748
751
|
test("same-user targeted request succeeds", async () => {
|
|
749
752
|
setup();
|
|
@@ -784,7 +787,7 @@ describe("HostBashProxy", () => {
|
|
|
784
787
|
);
|
|
785
788
|
|
|
786
789
|
expect(result.isError).toBe(true);
|
|
787
|
-
expect(result.content).toBe(
|
|
790
|
+
expect(result.content).toBe(CROSS_USER_REJECTION);
|
|
788
791
|
// No broadcast and no pending registration
|
|
789
792
|
expect(sentMessages).toHaveLength(0);
|
|
790
793
|
});
|
|
@@ -807,7 +810,9 @@ describe("HostBashProxy", () => {
|
|
|
807
810
|
);
|
|
808
811
|
|
|
809
812
|
expect(result.isError).toBe(true);
|
|
810
|
-
expect(result.content).
|
|
813
|
+
expect(result.content).toContain(
|
|
814
|
+
"registered without an authenticated user",
|
|
815
|
+
);
|
|
811
816
|
expect(sentMessages).toHaveLength(0);
|
|
812
817
|
});
|
|
813
818
|
|
|
@@ -823,7 +828,7 @@ describe("HostBashProxy", () => {
|
|
|
823
828
|
);
|
|
824
829
|
|
|
825
830
|
expect(result.isError).toBe(true);
|
|
826
|
-
expect(result.content).
|
|
831
|
+
expect(result.content).toContain("no authenticated actor");
|
|
827
832
|
expect(sentMessages).toHaveLength(0);
|
|
828
833
|
});
|
|
829
834
|
|
|
@@ -867,7 +867,7 @@ describe("HostBrowserProxy", () => {
|
|
|
867
867
|
);
|
|
868
868
|
|
|
869
869
|
expect(result.isError).toBe(true);
|
|
870
|
-
expect(result.content).toContain("
|
|
870
|
+
expect(result.content).toContain("signed in as a different user");
|
|
871
871
|
expect(getPublishedMessages()).toHaveLength(0);
|
|
872
872
|
});
|
|
873
873
|
|
|
@@ -1054,7 +1054,7 @@ describe("HostBrowserProxy", () => {
|
|
|
1054
1054
|
);
|
|
1055
1055
|
|
|
1056
1056
|
expect(result.isError).toBe(true);
|
|
1057
|
-
expect(result.content).toContain("
|
|
1057
|
+
expect(result.content).toContain("signed in as a different user");
|
|
1058
1058
|
expect(result.content).not.toContain("extension_required");
|
|
1059
1059
|
expect(getPublishedMessages()).toHaveLength(0);
|
|
1060
1060
|
});
|
|
@@ -1416,9 +1416,7 @@ describe("HostCuProxy", () => {
|
|
|
1416
1416
|
);
|
|
1417
1417
|
|
|
1418
1418
|
expect(result.isError).toBe(true);
|
|
1419
|
-
expect(result.content).toContain(
|
|
1420
|
-
"Submitting actor does not match the target client's actor",
|
|
1421
|
-
);
|
|
1419
|
+
expect(result.content).toContain("signed in as a different user");
|
|
1422
1420
|
expect(sentMessages).toHaveLength(0);
|
|
1423
1421
|
});
|
|
1424
1422
|
|
|
@@ -1444,9 +1442,7 @@ describe("HostCuProxy", () => {
|
|
|
1444
1442
|
);
|
|
1445
1443
|
|
|
1446
1444
|
expect(result.isError).toBe(true);
|
|
1447
|
-
expect(result.content).toContain(
|
|
1448
|
-
"Submitting actor does not match the target client's actor",
|
|
1449
|
-
);
|
|
1445
|
+
expect(result.content).toContain("no authenticated actor");
|
|
1450
1446
|
expect(sentMessages).toHaveLength(0);
|
|
1451
1447
|
});
|
|
1452
1448
|
|
|
@@ -1473,7 +1469,7 @@ describe("HostCuProxy", () => {
|
|
|
1473
1469
|
|
|
1474
1470
|
expect(result.isError).toBe(true);
|
|
1475
1471
|
expect(result.content).toContain(
|
|
1476
|
-
"
|
|
1472
|
+
"registered without an authenticated user",
|
|
1477
1473
|
);
|
|
1478
1474
|
expect(sentMessages).toHaveLength(0);
|
|
1479
1475
|
});
|
|
@@ -693,9 +693,7 @@ describe("HostFileProxy", () => {
|
|
|
693
693
|
);
|
|
694
694
|
|
|
695
695
|
expect(result.isError).toBe(true);
|
|
696
|
-
expect(result.content).toContain(
|
|
697
|
-
"Submitting actor does not match the target client's actor",
|
|
698
|
-
);
|
|
696
|
+
expect(result.content).toContain("signed in as a different user");
|
|
699
697
|
// No host_file_request was broadcast.
|
|
700
698
|
expect(sentMessages).toHaveLength(0);
|
|
701
699
|
});
|
|
@@ -720,7 +718,7 @@ describe("HostFileProxy", () => {
|
|
|
720
718
|
|
|
721
719
|
expect(result.isError).toBe(true);
|
|
722
720
|
expect(result.content).toContain(
|
|
723
|
-
"
|
|
721
|
+
"registered without an authenticated user",
|
|
724
722
|
);
|
|
725
723
|
expect(sentMessages).toHaveLength(0);
|
|
726
724
|
});
|
|
@@ -744,9 +742,7 @@ describe("HostFileProxy", () => {
|
|
|
744
742
|
);
|
|
745
743
|
|
|
746
744
|
expect(result.isError).toBe(true);
|
|
747
|
-
expect(result.content).toContain(
|
|
748
|
-
"Submitting actor does not match the target client's actor",
|
|
749
|
-
);
|
|
745
|
+
expect(result.content).toContain("no authenticated actor");
|
|
750
746
|
expect(sentMessages).toHaveLength(0);
|
|
751
747
|
});
|
|
752
748
|
|
|
@@ -27,6 +27,7 @@ mock.module("../daemon/host-file-proxy.js", () => ({
|
|
|
27
27
|
}));
|
|
28
28
|
|
|
29
29
|
import { hostFileReadTool } from "../tools/host-filesystem/read.js";
|
|
30
|
+
import { DEFAULT_READ_LINE_LIMIT } from "../tools/shared/filesystem/file-ops-service.js";
|
|
30
31
|
import type { ToolContext } from "../tools/types.js";
|
|
31
32
|
|
|
32
33
|
const testDirs: string[] = [];
|
|
@@ -226,13 +227,16 @@ describe("host_file_read image support", () => {
|
|
|
226
227
|
|
|
227
228
|
expect(result.isError).toBe(false);
|
|
228
229
|
expect(result.contentBlocks).toHaveLength(1);
|
|
230
|
+
// The proxied request carries the resolved default window, so a host read
|
|
231
|
+
// is bounded by the same limit as a local one rather than streaming a
|
|
232
|
+
// whole file across the bridge.
|
|
229
233
|
expect(requests).toEqual([
|
|
230
234
|
{
|
|
231
235
|
input: {
|
|
232
236
|
operation: "read",
|
|
233
237
|
path: "/host/screenshot.png",
|
|
234
238
|
offset: undefined,
|
|
235
|
-
limit:
|
|
239
|
+
limit: DEFAULT_READ_LINE_LIMIT,
|
|
236
240
|
},
|
|
237
241
|
conversationId: "test-conversation",
|
|
238
242
|
signal: undefined,
|