@vellumai/assistant 0.3.18 → 0.3.20
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 +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +260 -422
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +154 -0
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +6 -0
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that the voice bridge consumes scoped approval grants via the
|
|
3
|
+
* unified approval primitive before auto-denying non-guardian callers.
|
|
4
|
+
*
|
|
5
|
+
* Some confirmation_request events originate from proxy/network paths
|
|
6
|
+
* (e.g. PermissionPrompter in createProxyApprovalCallback) that bypass
|
|
7
|
+
* the pre-exec gate. The bridge must check for a matching scoped grant
|
|
8
|
+
* and allow the confirmation if one exists.
|
|
9
|
+
*
|
|
10
|
+
* Verifies:
|
|
11
|
+
* 1. Non-guardian confirmation requests are auto-allowed when a
|
|
12
|
+
* matching grant exists (bridge consumes it via the primitive).
|
|
13
|
+
* 2. Non-guardian confirmation requests are auto-denied when no
|
|
14
|
+
* matching grant exists.
|
|
15
|
+
* 3. Guardian auto-allow path remains unchanged.
|
|
16
|
+
* 4. Grants are revoked on call end (controller.destroy).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
24
|
+
|
|
25
|
+
const testDir = mkdtempSync(join(tmpdir(), 'voice-scoped-grant-consumer-test-'));
|
|
26
|
+
|
|
27
|
+
// ── Platform + logger mocks (must come before any source imports) ────
|
|
28
|
+
|
|
29
|
+
mock.module('../util/platform.js', () => ({
|
|
30
|
+
getRootDir: () => testDir,
|
|
31
|
+
getDataDir: () => testDir,
|
|
32
|
+
isMacOS: () => process.platform === 'darwin',
|
|
33
|
+
isLinux: () => process.platform === 'linux',
|
|
34
|
+
isWindows: () => process.platform === 'win32',
|
|
35
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
36
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
37
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
38
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
39
|
+
ensureDataDir: () => {},
|
|
40
|
+
readHttpToken: () => null,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mock.module('../util/logger.js', () => ({
|
|
44
|
+
getLogger: () =>
|
|
45
|
+
new Proxy({} as Record<string, unknown>, {
|
|
46
|
+
get: () => () => {},
|
|
47
|
+
}),
|
|
48
|
+
isDebug: () => false,
|
|
49
|
+
truncateForLog: (value: string) => value,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// ── Config mock ─────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
mock.module('../config/loader.js', () => ({
|
|
55
|
+
getConfig: () => ({
|
|
56
|
+
provider: 'anthropic',
|
|
57
|
+
providerOrder: ['anthropic'],
|
|
58
|
+
apiKeys: { anthropic: 'test-key' },
|
|
59
|
+
calls: {
|
|
60
|
+
enabled: true,
|
|
61
|
+
provider: 'twilio',
|
|
62
|
+
maxDurationSeconds: 12 * 60,
|
|
63
|
+
userConsultTimeoutSeconds: 90,
|
|
64
|
+
userConsultationTimeoutSeconds: 90,
|
|
65
|
+
silenceTimeoutSeconds: 30,
|
|
66
|
+
disclosure: { enabled: false, text: '' },
|
|
67
|
+
safety: { denyCategories: [] },
|
|
68
|
+
model: undefined,
|
|
69
|
+
},
|
|
70
|
+
memory: { enabled: false },
|
|
71
|
+
}),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// ── Secret ingress mock ────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
mock.module('../security/secret-ingress.js', () => ({
|
|
77
|
+
checkIngressForSecrets: () => ({ blocked: false }),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
// ── Assistant event hub mock ───────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
mock.module('../runtime/assistant-event-hub.js', () => ({
|
|
83
|
+
assistantEventHub: {
|
|
84
|
+
publish: async () => {},
|
|
85
|
+
},
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
mock.module('../runtime/assistant-event.js', () => ({
|
|
89
|
+
buildAssistantEvent: () => ({}),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
// ── Session runtime assembly mock ──────────────────────────────────
|
|
93
|
+
|
|
94
|
+
mock.module('../daemon/session-runtime-assembly.js', () => ({
|
|
95
|
+
resolveChannelCapabilities: () => ({
|
|
96
|
+
supportsRichText: false,
|
|
97
|
+
supportsDynamicUi: false,
|
|
98
|
+
supportsVoiceInput: true,
|
|
99
|
+
}),
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
// ── Import source modules after all mocks are registered ────────────
|
|
104
|
+
|
|
105
|
+
import { and, eq } from 'drizzle-orm';
|
|
106
|
+
|
|
107
|
+
import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
|
|
108
|
+
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
109
|
+
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
110
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
111
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
112
|
+
import {
|
|
113
|
+
_internal,
|
|
114
|
+
type CreateScopedApprovalGrantParams,
|
|
115
|
+
revokeScopedApprovalGrantsForContext,
|
|
116
|
+
} from '../memory/scoped-approval-grants.js';
|
|
117
|
+
|
|
118
|
+
const { createScopedApprovalGrant } = _internal;
|
|
119
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
120
|
+
|
|
121
|
+
initializeDb();
|
|
122
|
+
|
|
123
|
+
afterAll(() => {
|
|
124
|
+
resetDb();
|
|
125
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Mock session that triggers a confirmation_request on processMessage
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const TOOL_NAME = 'execute_shell';
|
|
133
|
+
const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
|
|
134
|
+
const ASSISTANT_ID = 'self';
|
|
135
|
+
const CONVERSATION_ID = 'conv-voice-grant-test';
|
|
136
|
+
const CALL_SESSION_ID = 'call-session-voice-grant-test';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a mock session that, when runAgentLoop is called, emits a
|
|
140
|
+
* confirmation_request through the updateClient callback before completing.
|
|
141
|
+
*/
|
|
142
|
+
function createMockSession(opts?: {
|
|
143
|
+
confirmationRequestId?: string;
|
|
144
|
+
toolName?: string;
|
|
145
|
+
toolInput?: Record<string, unknown>;
|
|
146
|
+
}) {
|
|
147
|
+
const requestId = opts?.confirmationRequestId ?? `req-${crypto.randomUUID()}`;
|
|
148
|
+
const toolName = opts?.toolName ?? TOOL_NAME;
|
|
149
|
+
const toolInput = opts?.toolInput ?? TOOL_INPUT;
|
|
150
|
+
|
|
151
|
+
let clientCallback: ((msg: ServerMessage) => void) | null = null;
|
|
152
|
+
let confirmationDecision: { requestId: string; decision: string; reason?: string } | null = null;
|
|
153
|
+
|
|
154
|
+
const session = {
|
|
155
|
+
isProcessing: () => false,
|
|
156
|
+
memoryPolicy: {},
|
|
157
|
+
setAssistantId: () => {},
|
|
158
|
+
setGuardianContext: () => {},
|
|
159
|
+
setCommandIntent: () => {},
|
|
160
|
+
setTurnChannelContext: () => {},
|
|
161
|
+
setChannelCapabilities: () => {},
|
|
162
|
+
setVoiceCallControlPrompt: () => {},
|
|
163
|
+
currentRequestId: requestId,
|
|
164
|
+
abort: () => {},
|
|
165
|
+
persistUserMessage: async () => 'msg-1',
|
|
166
|
+
updateClient: (cb: (msg: ServerMessage) => void, _reset?: boolean) => {
|
|
167
|
+
clientCallback = cb;
|
|
168
|
+
},
|
|
169
|
+
handleConfirmationResponse: (
|
|
170
|
+
reqId: string,
|
|
171
|
+
decision: string,
|
|
172
|
+
_pattern?: string,
|
|
173
|
+
_scope?: string,
|
|
174
|
+
reason?: string,
|
|
175
|
+
) => {
|
|
176
|
+
confirmationDecision = { requestId: reqId, decision, reason };
|
|
177
|
+
},
|
|
178
|
+
handleSecretResponse: () => {},
|
|
179
|
+
runAgentLoop: async (
|
|
180
|
+
_content: string,
|
|
181
|
+
_messageId: string,
|
|
182
|
+
broadcastFn: (msg: ServerMessage) => void,
|
|
183
|
+
) => {
|
|
184
|
+
// Emit a confirmation_request through the client callback
|
|
185
|
+
if (clientCallback) {
|
|
186
|
+
clientCallback({
|
|
187
|
+
type: 'confirmation_request',
|
|
188
|
+
requestId,
|
|
189
|
+
toolName,
|
|
190
|
+
input: toolInput,
|
|
191
|
+
riskLevel: 'medium',
|
|
192
|
+
allowlistOptions: [],
|
|
193
|
+
scopeOptions: [],
|
|
194
|
+
} as ServerMessage);
|
|
195
|
+
}
|
|
196
|
+
// Then complete the turn
|
|
197
|
+
broadcastFn({ type: 'message_complete' } as ServerMessage);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
session,
|
|
203
|
+
requestId,
|
|
204
|
+
getConfirmationDecision: () => confirmationDecision,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Setup: inject mock deps into voice-session-bridge
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
function setupBridgeDeps(sessionFactory: () => ReturnType<typeof createMockSession>['session']) {
|
|
213
|
+
let currentSession: ReturnType<typeof createMockSession>['session'] | null = null;
|
|
214
|
+
setVoiceBridgeDeps({
|
|
215
|
+
getOrCreateSession: async () => {
|
|
216
|
+
currentSession = sessionFactory();
|
|
217
|
+
return currentSession as any;
|
|
218
|
+
},
|
|
219
|
+
resolveAttachments: () => [],
|
|
220
|
+
deriveDefaultStrictSideEffects: () => true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Helpers
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function clearTables(): void {
|
|
229
|
+
const db = getDb();
|
|
230
|
+
try { db.run('DELETE FROM scoped_approval_grants'); } catch { /* table may not exist */ }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
|
|
234
|
+
const futureExpiry = new Date(Date.now() + 60_000).toISOString();
|
|
235
|
+
return {
|
|
236
|
+
assistantId: ASSISTANT_ID,
|
|
237
|
+
scopeMode: 'tool_signature',
|
|
238
|
+
toolName: TOOL_NAME,
|
|
239
|
+
inputDigest: computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT),
|
|
240
|
+
requestChannel: 'voice',
|
|
241
|
+
decisionChannel: 'telegram',
|
|
242
|
+
executionChannel: 'voice',
|
|
243
|
+
conversationId: CONVERSATION_ID,
|
|
244
|
+
callSessionId: CALL_SESSION_ID,
|
|
245
|
+
expiresAt: futureExpiry,
|
|
246
|
+
...overrides,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ===========================================================================
|
|
251
|
+
// Tests
|
|
252
|
+
// ===========================================================================
|
|
253
|
+
|
|
254
|
+
describe('voice bridge confirmation handling (grant consumption via primitive)', () => {
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
clearTables();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('non-guardian with matching grant: auto-allowed (bridge consumes grant via primitive)', async () => {
|
|
260
|
+
// A matching grant should be consumed and the confirmation allowed.
|
|
261
|
+
// This covers proxy/network confirmation requests that bypass the pre-exec gate.
|
|
262
|
+
createScopedApprovalGrant(grantParams());
|
|
263
|
+
|
|
264
|
+
const mockData = createMockSession();
|
|
265
|
+
setupBridgeDeps(() => mockData.session);
|
|
266
|
+
|
|
267
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
268
|
+
sourceChannel: 'voice',
|
|
269
|
+
actorRole: 'non-guardian',
|
|
270
|
+
requesterExternalUserId: 'caller-123',
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
await startVoiceTurn({
|
|
274
|
+
conversationId: CONVERSATION_ID,
|
|
275
|
+
callSessionId: CALL_SESSION_ID,
|
|
276
|
+
content: 'test utterance',
|
|
277
|
+
assistantId: ASSISTANT_ID,
|
|
278
|
+
guardianContext,
|
|
279
|
+
isInbound: true,
|
|
280
|
+
onTextDelta: () => {},
|
|
281
|
+
onComplete: () => {},
|
|
282
|
+
onError: () => {},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Wait for the async agent loop to finish
|
|
286
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
287
|
+
|
|
288
|
+
const decision = mockData.getConfirmationDecision();
|
|
289
|
+
expect(decision).not.toBeNull();
|
|
290
|
+
expect(decision!.decision).toBe('allow');
|
|
291
|
+
expect(decision!.reason).toContain('guardian pre-approved via scoped grant');
|
|
292
|
+
|
|
293
|
+
// The grant should be consumed (no longer active)
|
|
294
|
+
const db = getDb();
|
|
295
|
+
const activeGrants = db.select()
|
|
296
|
+
.from(scopedApprovalGrants)
|
|
297
|
+
.where(eq(scopedApprovalGrants.status, 'active'))
|
|
298
|
+
.all();
|
|
299
|
+
expect(activeGrants.length).toBe(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('non-guardian without grant: auto-denied', async () => {
|
|
303
|
+
// No grant created
|
|
304
|
+
|
|
305
|
+
const mockData = createMockSession();
|
|
306
|
+
setupBridgeDeps(() => mockData.session);
|
|
307
|
+
|
|
308
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
309
|
+
sourceChannel: 'voice',
|
|
310
|
+
actorRole: 'non-guardian',
|
|
311
|
+
requesterExternalUserId: 'caller-123',
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
await startVoiceTurn({
|
|
315
|
+
conversationId: CONVERSATION_ID,
|
|
316
|
+
callSessionId: CALL_SESSION_ID,
|
|
317
|
+
content: 'test utterance',
|
|
318
|
+
assistantId: ASSISTANT_ID,
|
|
319
|
+
guardianContext,
|
|
320
|
+
isInbound: true,
|
|
321
|
+
onTextDelta: () => {},
|
|
322
|
+
onComplete: () => {},
|
|
323
|
+
onError: () => {},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
327
|
+
|
|
328
|
+
const decision = mockData.getConfirmationDecision();
|
|
329
|
+
expect(decision).not.toBeNull();
|
|
330
|
+
expect(decision!.decision).toBe('deny');
|
|
331
|
+
expect(decision!.reason).toContain('Permission denied');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('non-guardian with mismatched tool name: auto-denied', async () => {
|
|
335
|
+
// Create a grant for a different tool
|
|
336
|
+
createScopedApprovalGrant(grantParams({
|
|
337
|
+
toolName: 'read_file',
|
|
338
|
+
inputDigest: computeToolApprovalDigest('read_file', TOOL_INPUT),
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
const mockData = createMockSession();
|
|
342
|
+
setupBridgeDeps(() => mockData.session);
|
|
343
|
+
|
|
344
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
345
|
+
sourceChannel: 'voice',
|
|
346
|
+
actorRole: 'non-guardian',
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
await startVoiceTurn({
|
|
350
|
+
conversationId: CONVERSATION_ID,
|
|
351
|
+
callSessionId: CALL_SESSION_ID,
|
|
352
|
+
content: 'test utterance',
|
|
353
|
+
assistantId: ASSISTANT_ID,
|
|
354
|
+
guardianContext,
|
|
355
|
+
isInbound: true,
|
|
356
|
+
onTextDelta: () => {},
|
|
357
|
+
onComplete: () => {},
|
|
358
|
+
onError: () => {},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
362
|
+
|
|
363
|
+
const decision = mockData.getConfirmationDecision();
|
|
364
|
+
expect(decision).not.toBeNull();
|
|
365
|
+
expect(decision!.decision).toBe('deny');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('guardian caller: auto-allowed regardless of grants', async () => {
|
|
369
|
+
// No grant needed — guardian should auto-allow
|
|
370
|
+
|
|
371
|
+
const mockData = createMockSession();
|
|
372
|
+
setupBridgeDeps(() => mockData.session);
|
|
373
|
+
|
|
374
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
375
|
+
sourceChannel: 'voice',
|
|
376
|
+
actorRole: 'guardian',
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
await startVoiceTurn({
|
|
380
|
+
conversationId: CONVERSATION_ID,
|
|
381
|
+
callSessionId: CALL_SESSION_ID,
|
|
382
|
+
content: 'test utterance',
|
|
383
|
+
assistantId: ASSISTANT_ID,
|
|
384
|
+
guardianContext,
|
|
385
|
+
isInbound: true,
|
|
386
|
+
onTextDelta: () => {},
|
|
387
|
+
onComplete: () => {},
|
|
388
|
+
onError: () => {},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
392
|
+
|
|
393
|
+
const decision = mockData.getConfirmationDecision();
|
|
394
|
+
expect(decision).not.toBeNull();
|
|
395
|
+
expect(decision!.decision).toBe('allow');
|
|
396
|
+
expect(decision!.reason).toContain('guardian voice call');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('non-guardian with grant for different assistantId: auto-denied', async () => {
|
|
400
|
+
// Create a grant scoped to a different assistant
|
|
401
|
+
createScopedApprovalGrant(grantParams({
|
|
402
|
+
assistantId: 'other-assistant',
|
|
403
|
+
}));
|
|
404
|
+
|
|
405
|
+
const mockData = createMockSession();
|
|
406
|
+
setupBridgeDeps(() => mockData.session);
|
|
407
|
+
|
|
408
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
409
|
+
sourceChannel: 'voice',
|
|
410
|
+
actorRole: 'non-guardian',
|
|
411
|
+
requesterExternalUserId: 'caller-123',
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
await startVoiceTurn({
|
|
415
|
+
conversationId: CONVERSATION_ID,
|
|
416
|
+
callSessionId: CALL_SESSION_ID,
|
|
417
|
+
content: 'test utterance',
|
|
418
|
+
assistantId: ASSISTANT_ID,
|
|
419
|
+
guardianContext,
|
|
420
|
+
isInbound: true,
|
|
421
|
+
onTextDelta: () => {},
|
|
422
|
+
onComplete: () => {},
|
|
423
|
+
onError: () => {},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
427
|
+
|
|
428
|
+
const decision = mockData.getConfirmationDecision();
|
|
429
|
+
expect(decision).not.toBeNull();
|
|
430
|
+
expect(decision!.decision).toBe('deny');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('grants revoked when revokeScopedApprovalGrantsForContext is called with callSessionId', () => {
|
|
434
|
+
const db = getDb();
|
|
435
|
+
const testCallSessionId = 'call-session-revoke-test';
|
|
436
|
+
|
|
437
|
+
// Create two grants: one for our call session, one for another
|
|
438
|
+
createScopedApprovalGrant(grantParams({ callSessionId: testCallSessionId }));
|
|
439
|
+
createScopedApprovalGrant(grantParams({ callSessionId: 'other-call-session' }));
|
|
440
|
+
|
|
441
|
+
// Verify both grants are active
|
|
442
|
+
const allActive = db.select()
|
|
443
|
+
.from(scopedApprovalGrants)
|
|
444
|
+
.where(eq(scopedApprovalGrants.status, 'active'))
|
|
445
|
+
.all();
|
|
446
|
+
expect(allActive.length).toBe(2);
|
|
447
|
+
|
|
448
|
+
// Revoke grants for the specific call session (simulates call end)
|
|
449
|
+
const revokedCount = revokeScopedApprovalGrantsForContext({ callSessionId: testCallSessionId });
|
|
450
|
+
expect(revokedCount).toBe(1);
|
|
451
|
+
|
|
452
|
+
// Only the target call session's grant should be revoked
|
|
453
|
+
const activeAfter = db.select()
|
|
454
|
+
.from(scopedApprovalGrants)
|
|
455
|
+
.where(and(
|
|
456
|
+
eq(scopedApprovalGrants.callSessionId, testCallSessionId),
|
|
457
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
458
|
+
))
|
|
459
|
+
.all();
|
|
460
|
+
expect(activeAfter.length).toBe(0);
|
|
461
|
+
|
|
462
|
+
const revokedAfter = db.select()
|
|
463
|
+
.from(scopedApprovalGrants)
|
|
464
|
+
.where(and(
|
|
465
|
+
eq(scopedApprovalGrants.callSessionId, testCallSessionId),
|
|
466
|
+
eq(scopedApprovalGrants.status, 'revoked'),
|
|
467
|
+
))
|
|
468
|
+
.all();
|
|
469
|
+
expect(revokedAfter.length).toBe(1);
|
|
470
|
+
|
|
471
|
+
// The other call session's grant should still be active
|
|
472
|
+
const otherActive = db.select()
|
|
473
|
+
.from(scopedApprovalGrants)
|
|
474
|
+
.where(and(
|
|
475
|
+
eq(scopedApprovalGrants.callSessionId, 'other-call-session'),
|
|
476
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
477
|
+
))
|
|
478
|
+
.all();
|
|
479
|
+
expect(otherActive.length).toBe(1);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('grants with null callSessionId are revoked by conversationId', () => {
|
|
483
|
+
const db = getDb();
|
|
484
|
+
const testConversationId = 'conv-revoke-by-conversation';
|
|
485
|
+
|
|
486
|
+
// Simulate the guardian-approval-interception minting path which sets
|
|
487
|
+
// callSessionId: null but always sets conversationId
|
|
488
|
+
createScopedApprovalGrant(grantParams({
|
|
489
|
+
callSessionId: null,
|
|
490
|
+
conversationId: testConversationId,
|
|
491
|
+
}));
|
|
492
|
+
createScopedApprovalGrant(grantParams({
|
|
493
|
+
callSessionId: null,
|
|
494
|
+
conversationId: 'other-conversation',
|
|
495
|
+
}));
|
|
496
|
+
|
|
497
|
+
// Verify both grants are active
|
|
498
|
+
const allActive = db.select()
|
|
499
|
+
.from(scopedApprovalGrants)
|
|
500
|
+
.where(eq(scopedApprovalGrants.status, 'active'))
|
|
501
|
+
.all();
|
|
502
|
+
expect(allActive.length).toBe(2);
|
|
503
|
+
|
|
504
|
+
// callSessionId-based revocation should miss grants with null callSessionId
|
|
505
|
+
// because the filter matches on the column value, not NULL
|
|
506
|
+
const revokedByCallSession = revokeScopedApprovalGrantsForContext({ callSessionId: CALL_SESSION_ID });
|
|
507
|
+
expect(revokedByCallSession).toBe(0);
|
|
508
|
+
|
|
509
|
+
// conversationId-based revocation catches the grant
|
|
510
|
+
const revokedByConversation = revokeScopedApprovalGrantsForContext({ conversationId: testConversationId });
|
|
511
|
+
expect(revokedByConversation).toBe(1);
|
|
512
|
+
|
|
513
|
+
// The target conversation's grant should be revoked
|
|
514
|
+
const revokedAfter = db.select()
|
|
515
|
+
.from(scopedApprovalGrants)
|
|
516
|
+
.where(and(
|
|
517
|
+
eq(scopedApprovalGrants.conversationId, testConversationId),
|
|
518
|
+
eq(scopedApprovalGrants.status, 'revoked'),
|
|
519
|
+
))
|
|
520
|
+
.all();
|
|
521
|
+
expect(revokedAfter.length).toBe(1);
|
|
522
|
+
|
|
523
|
+
// The other conversation's grant should still be active
|
|
524
|
+
const otherActive = db.select()
|
|
525
|
+
.from(scopedApprovalGrants)
|
|
526
|
+
.where(and(
|
|
527
|
+
eq(scopedApprovalGrants.conversationId, 'other-conversation'),
|
|
528
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
529
|
+
))
|
|
530
|
+
.all();
|
|
531
|
+
expect(otherActive.length).toBe(1);
|
|
532
|
+
});
|
|
533
|
+
});
|
package/src/agent/loop.ts
CHANGED
|
@@ -312,6 +312,31 @@ export class AgentLoop {
|
|
|
312
312
|
break;
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// Guard against dual-control-mode conflicts in a single turn.
|
|
316
|
+
// If the model escalates to foreground computer control, browser_* tools
|
|
317
|
+
// in the same response create competing browser sessions/windows and can
|
|
318
|
+
// thrash renderer CPU. Reject browser_* calls in that turn.
|
|
319
|
+
const hasComputerUseEscalation = toolUseBlocks.some(
|
|
320
|
+
(toolUse) => toolUse.name === 'computer_use_request_control',
|
|
321
|
+
);
|
|
322
|
+
const blockedBrowserToolIds = hasComputerUseEscalation
|
|
323
|
+
? new Set(
|
|
324
|
+
toolUseBlocks
|
|
325
|
+
.filter((toolUse) => toolUse.name.startsWith('browser_'))
|
|
326
|
+
.map((toolUse) => toolUse.id),
|
|
327
|
+
)
|
|
328
|
+
: new Set<string>();
|
|
329
|
+
|
|
330
|
+
if (blockedBrowserToolIds.size > 0) {
|
|
331
|
+
log.warn(
|
|
332
|
+
{
|
|
333
|
+
blockedBrowserToolCount: blockedBrowserToolIds.size,
|
|
334
|
+
toolNames: toolUseBlocks.map((toolUse) => toolUse.name),
|
|
335
|
+
},
|
|
336
|
+
'Blocking browser_* tools: computer_use_request_control was requested in same turn',
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
315
340
|
// Execute all tools concurrently for reduced latency.
|
|
316
341
|
// Race against the abort signal so cancellation isn't blocked by
|
|
317
342
|
// stuck tools (e.g. a hung browser navigation).
|
|
@@ -319,6 +344,16 @@ export class AgentLoop {
|
|
|
319
344
|
toolUseBlocks.map(async (toolUse) => {
|
|
320
345
|
const toolStart = Date.now();
|
|
321
346
|
|
|
347
|
+
if (blockedBrowserToolIds.has(toolUse.id)) {
|
|
348
|
+
return {
|
|
349
|
+
toolUse,
|
|
350
|
+
result: {
|
|
351
|
+
content: 'Error: browser_* tools cannot run in the same turn as computer_use_request_control. Continue using the foreground computer-use session only.',
|
|
352
|
+
isError: true,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
322
357
|
const result = await this.toolExecutor!(toolUse.name, toolUse.input, (chunk) => {
|
|
323
358
|
onEvent({ type: 'tool_output_chunk', toolUseId: toolUse.id, chunk });
|
|
324
359
|
});
|
|
@@ -431,7 +466,7 @@ export class AgentLoop {
|
|
|
431
466
|
if (hasTextBlock) {
|
|
432
467
|
resultBlocks.push({
|
|
433
468
|
type: 'text',
|
|
434
|
-
text: '<system_notice>Your previous text was already
|
|
469
|
+
text: '<system_notice>Your previous text was already shown to the user in real time. Do not repeat or rephrase it. Do not narrate retries or internal process chatter ("let me try", "that didn\'t work"). Keep working with tools silently unless you need user input, and only send user-facing text when you have concrete progress or final results.</system_notice>',
|
|
435
470
|
});
|
|
436
471
|
}
|
|
437
472
|
|