@vellumai/assistant 0.3.19 → 0.3.21
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 +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/bun.lock +139 -2
- package/docs/architecture/integrations.md +7 -11
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -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 +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -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 +300 -32
- 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 +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- 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 +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -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 +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- 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__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- 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 +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli/mcp.ts +58 -0
- 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 +1 -1
- 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/mcp-schema.ts +46 -0
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +18 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- 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 +6 -5
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -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/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/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-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/providers-setup.ts +26 -1
- 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 +258 -432
- 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/shutdown-handlers.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +2 -2
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- 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 +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- 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/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 +92 -35
- 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 -190
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- 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 +1 -1
- 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/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/registry.ts +64 -1
- 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 +10 -2
- 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
|
@@ -175,6 +175,24 @@ describe('composeThreadSeed', () => {
|
|
|
175
175
|
expect(seed).toContain('Action required');
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
test('does not duplicate "Action required" when copy already includes it', () => {
|
|
179
|
+
const signal = makeSignal({
|
|
180
|
+
attentionHints: {
|
|
181
|
+
requiresAction: true,
|
|
182
|
+
urgency: 'high',
|
|
183
|
+
isAsyncBackground: false,
|
|
184
|
+
visibleInSourceNow: false,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const copy = makeCopy({
|
|
188
|
+
title: 'Guardian Question',
|
|
189
|
+
body: 'Action required: What is the gate code?',
|
|
190
|
+
});
|
|
191
|
+
const seed = composeThreadSeed(signal, 'vellum' as NotificationChannel, copy);
|
|
192
|
+
const markerCount = (seed.match(/action required/gi) ?? []).length;
|
|
193
|
+
expect(markerCount).toBe(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
178
196
|
test('omits "Notification" generic title', () => {
|
|
179
197
|
const signal = makeSignal();
|
|
180
198
|
const copy = makeCopy({ title: 'Notification', body: 'Something new.' });
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'tool-approval-handler-test-'));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === 'darwin',
|
|
12
|
+
isLinux: () => process.platform === 'linux',
|
|
13
|
+
isWindows: () => process.platform === 'win32',
|
|
14
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
15
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
16
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
17
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
18
|
+
ensureDataDir: () => {},
|
|
19
|
+
migrateToDataLayout: () => {},
|
|
20
|
+
migrateToWorkspaceLayout: () => {},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
mock.module('../util/logger.js', () => ({
|
|
24
|
+
getLogger: () =>
|
|
25
|
+
new Proxy({} as Record<string, unknown>, {
|
|
26
|
+
get: () => () => {},
|
|
27
|
+
}),
|
|
28
|
+
isDebug: () => false,
|
|
29
|
+
truncateForLog: (value: string) => value,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock parental controls — no tools blocked by default
|
|
33
|
+
mock.module('../security/parental-control-store.js', () => ({
|
|
34
|
+
isToolBlocked: () => false,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// Mock guardian control-plane policy — not targeting control-plane by default
|
|
38
|
+
mock.module('../tools/guardian-control-plane-policy.js', () => ({
|
|
39
|
+
enforceGuardianOnlyPolicy: () => ({ denied: false }),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// Mock task run rules — no task run rules by default
|
|
43
|
+
mock.module('../tasks/ephemeral-permissions.js', () => ({
|
|
44
|
+
getTaskRunRules: () => [],
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// Mock tool registry — return a fake tool for 'bash'
|
|
48
|
+
const fakeTool = {
|
|
49
|
+
name: 'bash',
|
|
50
|
+
description: 'Run a shell command',
|
|
51
|
+
category: 'shell',
|
|
52
|
+
defaultRiskLevel: 'high',
|
|
53
|
+
getDefinition: () => ({ name: 'bash', description: 'Run a shell command', input_schema: {} }),
|
|
54
|
+
execute: async () => ({ content: 'ok', isError: false }),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
mock.module('../tools/registry.js', () => ({
|
|
58
|
+
getTool: (name: string) => (name === 'bash' ? fakeTool : undefined),
|
|
59
|
+
getAllTools: () => [fakeTool],
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
import { mintGrantFromDecision, type MintGrantParams } from '../approvals/approval-primitive.js';
|
|
63
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
64
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
65
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
66
|
+
import { ToolApprovalHandler } from '../tools/tool-approval-handler.js';
|
|
67
|
+
import type { ToolContext, ToolLifecycleEvent } from '../tools/types.js';
|
|
68
|
+
|
|
69
|
+
initializeDb();
|
|
70
|
+
|
|
71
|
+
function clearTables(): void {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
db.delete(scopedApprovalGrants).run();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
resetDb();
|
|
78
|
+
try {
|
|
79
|
+
rmSync(testDir, { recursive: true });
|
|
80
|
+
} catch {
|
|
81
|
+
/* best effort */
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function mintParams(overrides: Partial<MintGrantParams> = {}): MintGrantParams {
|
|
90
|
+
const futureExpiry = new Date(Date.now() + 60_000).toISOString();
|
|
91
|
+
return {
|
|
92
|
+
assistantId: 'self',
|
|
93
|
+
scopeMode: 'tool_signature',
|
|
94
|
+
requestChannel: 'telegram',
|
|
95
|
+
decisionChannel: 'telegram',
|
|
96
|
+
expiresAt: futureExpiry,
|
|
97
|
+
...overrides,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
|
|
102
|
+
return {
|
|
103
|
+
workingDir: testDir,
|
|
104
|
+
sessionId: 'session-1',
|
|
105
|
+
conversationId: 'conv-1',
|
|
106
|
+
assistantId: 'self',
|
|
107
|
+
requestId: 'req-1',
|
|
108
|
+
guardianActorRole: 'non-guardian',
|
|
109
|
+
...overrides,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ===========================================================================
|
|
114
|
+
// TESTS
|
|
115
|
+
// ===========================================================================
|
|
116
|
+
|
|
117
|
+
describe('ToolApprovalHandler / pre-exec gate grant check', () => {
|
|
118
|
+
const handler = new ToolApprovalHandler();
|
|
119
|
+
const events: ToolLifecycleEvent[] = [];
|
|
120
|
+
const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
clearTables();
|
|
124
|
+
events.length = 0;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('untrusted actor + matching tool_signature grant -> allow', async () => {
|
|
128
|
+
const toolName = 'bash';
|
|
129
|
+
const input = { command: 'ls -la' };
|
|
130
|
+
const digest = computeToolApprovalDigest(toolName, input);
|
|
131
|
+
|
|
132
|
+
// Mint a grant that matches the invocation
|
|
133
|
+
const mintResult = mintGrantFromDecision(
|
|
134
|
+
mintParams({
|
|
135
|
+
scopeMode: 'tool_signature',
|
|
136
|
+
toolName,
|
|
137
|
+
inputDigest: digest,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(mintResult.ok).toBe(true);
|
|
141
|
+
|
|
142
|
+
const context = makeContext({ guardianActorRole: 'non-guardian' });
|
|
143
|
+
const result = await handler.checkPreExecutionGates(
|
|
144
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(result.allowed).toBe(true);
|
|
148
|
+
// No permission_denied events should have been emitted
|
|
149
|
+
const deniedEvents = events.filter((e) => e.type === 'permission_denied');
|
|
150
|
+
expect(deniedEvents.length).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('untrusted actor + no matching grant -> deny with guardian_approval_required', async () => {
|
|
154
|
+
const toolName = 'bash';
|
|
155
|
+
const input = { command: 'rm -rf /' };
|
|
156
|
+
|
|
157
|
+
const context = makeContext({ guardianActorRole: 'non-guardian' });
|
|
158
|
+
const result = await handler.checkPreExecutionGates(
|
|
159
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(result.allowed).toBe(false);
|
|
163
|
+
if (result.allowed) return;
|
|
164
|
+
expect(result.result.isError).toBe(true);
|
|
165
|
+
expect(result.result.content).toContain('guardian approval');
|
|
166
|
+
|
|
167
|
+
// A permission_denied event should have been emitted
|
|
168
|
+
const deniedEvents = events.filter((e) => e.type === 'permission_denied');
|
|
169
|
+
expect(deniedEvents.length).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('unverified_channel actor + matching grant -> allow', async () => {
|
|
173
|
+
const toolName = 'bash';
|
|
174
|
+
const input = { command: 'echo hello' };
|
|
175
|
+
const digest = computeToolApprovalDigest(toolName, input);
|
|
176
|
+
|
|
177
|
+
mintGrantFromDecision(
|
|
178
|
+
mintParams({
|
|
179
|
+
scopeMode: 'tool_signature',
|
|
180
|
+
toolName,
|
|
181
|
+
inputDigest: digest,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const context = makeContext({ guardianActorRole: 'unverified_channel' });
|
|
186
|
+
const result = await handler.checkPreExecutionGates(
|
|
187
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(result.allowed).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('unverified_channel actor + no grant -> deny', async () => {
|
|
194
|
+
const toolName = 'bash';
|
|
195
|
+
const input = { command: 'deploy' };
|
|
196
|
+
|
|
197
|
+
const context = makeContext({ guardianActorRole: 'unverified_channel' });
|
|
198
|
+
const result = await handler.checkPreExecutionGates(
|
|
199
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(result.allowed).toBe(false);
|
|
203
|
+
if (result.allowed) return;
|
|
204
|
+
expect(result.result.content).toContain('verified channel identity');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('grant is one-time: second invocation with same input denied', async () => {
|
|
208
|
+
const toolName = 'bash';
|
|
209
|
+
const input = { command: 'ls' };
|
|
210
|
+
const digest = computeToolApprovalDigest(toolName, input);
|
|
211
|
+
|
|
212
|
+
mintGrantFromDecision(
|
|
213
|
+
mintParams({
|
|
214
|
+
scopeMode: 'tool_signature',
|
|
215
|
+
toolName,
|
|
216
|
+
inputDigest: digest,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const context = makeContext({ guardianActorRole: 'non-guardian' });
|
|
221
|
+
|
|
222
|
+
// First invocation — should consume the grant and allow
|
|
223
|
+
const first = await handler.checkPreExecutionGates(
|
|
224
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
225
|
+
);
|
|
226
|
+
expect(first.allowed).toBe(true);
|
|
227
|
+
|
|
228
|
+
// Second invocation — grant already consumed, should deny
|
|
229
|
+
const second = await handler.checkPreExecutionGates(
|
|
230
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
231
|
+
);
|
|
232
|
+
expect(second.allowed).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('grant with mismatched input digest -> deny', async () => {
|
|
236
|
+
const toolName = 'bash';
|
|
237
|
+
const grantInput = { command: 'ls' };
|
|
238
|
+
const invokeInput = { command: 'rm -rf /' };
|
|
239
|
+
const grantDigest = computeToolApprovalDigest(toolName, grantInput);
|
|
240
|
+
|
|
241
|
+
mintGrantFromDecision(
|
|
242
|
+
mintParams({
|
|
243
|
+
scopeMode: 'tool_signature',
|
|
244
|
+
toolName,
|
|
245
|
+
inputDigest: grantDigest,
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const context = makeContext({ guardianActorRole: 'non-guardian' });
|
|
250
|
+
const result = await handler.checkPreExecutionGates(
|
|
251
|
+
toolName, invokeInput, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(result.allowed).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('expired grant -> deny', async () => {
|
|
258
|
+
const toolName = 'bash';
|
|
259
|
+
const input = { command: 'ls' };
|
|
260
|
+
const digest = computeToolApprovalDigest(toolName, input);
|
|
261
|
+
const pastExpiry = new Date(Date.now() - 60_000).toISOString();
|
|
262
|
+
|
|
263
|
+
mintGrantFromDecision(
|
|
264
|
+
mintParams({
|
|
265
|
+
scopeMode: 'tool_signature',
|
|
266
|
+
toolName,
|
|
267
|
+
inputDigest: digest,
|
|
268
|
+
expiresAt: pastExpiry,
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const context = makeContext({ guardianActorRole: 'non-guardian' });
|
|
273
|
+
const result = await handler.checkPreExecutionGates(
|
|
274
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(result.allowed).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('guardian actor bypasses grant check entirely (no grant needed)', async () => {
|
|
281
|
+
const toolName = 'bash';
|
|
282
|
+
const input = { command: 'deploy' };
|
|
283
|
+
|
|
284
|
+
// No grants minted at all
|
|
285
|
+
const context = makeContext({ guardianActorRole: 'guardian' });
|
|
286
|
+
const result = await handler.checkPreExecutionGates(
|
|
287
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Guardian should pass through — the untrusted gate is not triggered
|
|
291
|
+
expect(result.allowed).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('undefined actor role (desktop/trusted) bypasses grant check', async () => {
|
|
295
|
+
const toolName = 'bash';
|
|
296
|
+
const input = { command: 'deploy' };
|
|
297
|
+
|
|
298
|
+
const context = makeContext({ guardianActorRole: undefined });
|
|
299
|
+
const result = await handler.checkPreExecutionGates(
|
|
300
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(result.allowed).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('grant with matching request_id scope -> allow', async () => {
|
|
307
|
+
const toolName = 'bash';
|
|
308
|
+
const input = { command: 'ls' };
|
|
309
|
+
|
|
310
|
+
mintGrantFromDecision(
|
|
311
|
+
mintParams({
|
|
312
|
+
scopeMode: 'request_id',
|
|
313
|
+
requestId: 'req-1',
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const context = makeContext({ guardianActorRole: 'non-guardian', requestId: 'req-1' });
|
|
318
|
+
const result = await handler.checkPreExecutionGates(
|
|
319
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(result.allowed).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('grant with context fields (conversationId) must match', async () => {
|
|
326
|
+
const toolName = 'bash';
|
|
327
|
+
const input = { command: 'ls' };
|
|
328
|
+
const digest = computeToolApprovalDigest(toolName, input);
|
|
329
|
+
|
|
330
|
+
mintGrantFromDecision(
|
|
331
|
+
mintParams({
|
|
332
|
+
scopeMode: 'tool_signature',
|
|
333
|
+
toolName,
|
|
334
|
+
inputDigest: digest,
|
|
335
|
+
conversationId: 'conv-other',
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Context conversationId does not match the grant's conversationId
|
|
340
|
+
const context = makeContext({
|
|
341
|
+
guardianActorRole: 'non-guardian',
|
|
342
|
+
conversationId: 'conv-1',
|
|
343
|
+
});
|
|
344
|
+
const result = await handler.checkPreExecutionGates(
|
|
345
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(result.allowed).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -276,7 +276,9 @@ describe('trusted contact lifecycle notification signals', () => {
|
|
|
276
276
|
|
|
277
277
|
await handleChannelInbound(guardianReq, undefined, TEST_BEARER_TOKEN);
|
|
278
278
|
|
|
279
|
-
//
|
|
279
|
+
// guardian_decision should NOT fire at approval time when verification
|
|
280
|
+
// is still pending — it would cause the notification pipeline to send a
|
|
281
|
+
// premature "approved" message to the guardian's chat.
|
|
280
282
|
const guardianDecisionSignals = emitSignalCalls.filter(
|
|
281
283
|
(c) => c.sourceEventName === 'ingress.trusted_contact.guardian_decision',
|
|
282
284
|
);
|
|
@@ -284,19 +286,15 @@ describe('trusted contact lifecycle notification signals', () => {
|
|
|
284
286
|
(c) => c.sourceEventName === 'ingress.trusted_contact.verification_sent',
|
|
285
287
|
);
|
|
286
288
|
|
|
287
|
-
expect(guardianDecisionSignals.length).toBe(
|
|
289
|
+
expect(guardianDecisionSignals.length).toBe(0);
|
|
288
290
|
expect(verificationSentSignals.length).toBe(1);
|
|
289
291
|
|
|
290
|
-
// Verify
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
expect(gdPayload.requesterExternalUserId).toBe('requester-user-456');
|
|
294
|
-
expect(gdPayload.decidedByExternalUserId).toBe('guardian-user-789');
|
|
295
|
-
|
|
296
|
-
// Verify verification_sent payload
|
|
297
|
-
const vsPayload = verificationSentSignals[0].contextPayload as Record<string, unknown>;
|
|
292
|
+
// Verify verification_sent payload and that it's suppressed from delivery
|
|
293
|
+
const vsSignal = verificationSentSignals[0];
|
|
294
|
+
const vsPayload = vsSignal.contextPayload as Record<string, unknown>;
|
|
298
295
|
expect(vsPayload.requesterExternalUserId).toBe('requester-user-456');
|
|
299
296
|
expect(vsPayload.verificationSessionId).toBeDefined();
|
|
297
|
+
expect((vsSignal.attentionHints as Record<string, unknown>).visibleInSourceNow).toBe(true);
|
|
300
298
|
|
|
301
299
|
// Should NOT emit denied signal
|
|
302
300
|
const deniedSignals = emitSignalCalls.filter(
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests
|
|
3
|
-
* non-guardian
|
|
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.
|
|
4
9
|
*
|
|
5
10
|
* Verifies:
|
|
6
|
-
* 1.
|
|
7
|
-
*
|
|
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.
|
|
8
15
|
* 3. Guardian auto-allow path remains unchanged.
|
|
9
16
|
* 4. Grants are revoked on call end (controller.destroy).
|
|
10
|
-
* 5. Second identical invocation after consume is denied (one-time use).
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
20
|
import { tmpdir } from 'node:os';
|
|
15
21
|
import { join } from 'node:path';
|
|
16
22
|
|
|
17
|
-
import { afterAll, beforeEach, describe, expect,
|
|
23
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
18
24
|
|
|
19
25
|
const testDir = mkdtempSync(join(tmpdir(), 'voice-scoped-grant-consumer-test-'));
|
|
20
26
|
|
|
@@ -98,17 +104,19 @@ mock.module('../daemon/session-runtime-assembly.js', () => ({
|
|
|
98
104
|
|
|
99
105
|
import { and, eq } from 'drizzle-orm';
|
|
100
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';
|
|
101
112
|
import {
|
|
102
|
-
|
|
113
|
+
_internal,
|
|
103
114
|
type CreateScopedApprovalGrantParams,
|
|
104
115
|
revokeScopedApprovalGrantsForContext,
|
|
105
116
|
} from '../memory/scoped-approval-grants.js';
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
|
|
118
|
+
const { createScopedApprovalGrant } = _internal;
|
|
108
119
|
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
109
|
-
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
110
|
-
import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
|
|
111
|
-
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
112
120
|
|
|
113
121
|
initializeDb();
|
|
114
122
|
|
|
@@ -243,13 +251,14 @@ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}):
|
|
|
243
251
|
// Tests
|
|
244
252
|
// ===========================================================================
|
|
245
253
|
|
|
246
|
-
describe('voice
|
|
254
|
+
describe('voice bridge confirmation handling (grant consumption via primitive)', () => {
|
|
247
255
|
beforeEach(() => {
|
|
248
256
|
clearTables();
|
|
249
257
|
});
|
|
250
258
|
|
|
251
|
-
test('non-guardian with matching grant:
|
|
252
|
-
//
|
|
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.
|
|
253
262
|
createScopedApprovalGrant(grantParams());
|
|
254
263
|
|
|
255
264
|
const mockData = createMockSession();
|
|
@@ -261,7 +270,7 @@ describe('voice scoped grant consumer', () => {
|
|
|
261
270
|
requesterExternalUserId: 'caller-123',
|
|
262
271
|
};
|
|
263
272
|
|
|
264
|
-
|
|
273
|
+
await startVoiceTurn({
|
|
265
274
|
conversationId: CONVERSATION_ID,
|
|
266
275
|
callSessionId: CALL_SESSION_ID,
|
|
267
276
|
content: 'test utterance',
|
|
@@ -279,7 +288,15 @@ describe('voice scoped grant consumer', () => {
|
|
|
279
288
|
const decision = mockData.getConfirmationDecision();
|
|
280
289
|
expect(decision).not.toBeNull();
|
|
281
290
|
expect(decision!.decision).toBe('allow');
|
|
282
|
-
expect(decision!.reason).toContain('scoped grant');
|
|
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);
|
|
283
300
|
});
|
|
284
301
|
|
|
285
302
|
test('non-guardian without grant: auto-denied', async () => {
|
|
@@ -294,7 +311,7 @@ describe('voice scoped grant consumer', () => {
|
|
|
294
311
|
requesterExternalUserId: 'caller-123',
|
|
295
312
|
};
|
|
296
313
|
|
|
297
|
-
|
|
314
|
+
await startVoiceTurn({
|
|
298
315
|
conversationId: CONVERSATION_ID,
|
|
299
316
|
callSessionId: CALL_SESSION_ID,
|
|
300
317
|
content: 'test utterance',
|
|
@@ -379,13 +396,14 @@ describe('voice scoped grant consumer', () => {
|
|
|
379
396
|
expect(decision!.reason).toContain('guardian voice call');
|
|
380
397
|
});
|
|
381
398
|
|
|
382
|
-
test('
|
|
383
|
-
// Create a
|
|
384
|
-
createScopedApprovalGrant(grantParams(
|
|
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
|
+
}));
|
|
385
404
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
setupBridgeDeps(() => mockData1.session);
|
|
405
|
+
const mockData = createMockSession();
|
|
406
|
+
setupBridgeDeps(() => mockData.session);
|
|
389
407
|
|
|
390
408
|
const guardianContext: GuardianRuntimeContext = {
|
|
391
409
|
sourceChannel: 'voice',
|
|
@@ -396,29 +414,7 @@ describe('voice scoped grant consumer', () => {
|
|
|
396
414
|
await startVoiceTurn({
|
|
397
415
|
conversationId: CONVERSATION_ID,
|
|
398
416
|
callSessionId: CALL_SESSION_ID,
|
|
399
|
-
content: '
|
|
400
|
-
assistantId: ASSISTANT_ID,
|
|
401
|
-
guardianContext,
|
|
402
|
-
isInbound: true,
|
|
403
|
-
onTextDelta: () => {},
|
|
404
|
-
onComplete: () => {},
|
|
405
|
-
onError: () => {},
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
409
|
-
|
|
410
|
-
const decision1 = mockData1.getConfirmationDecision();
|
|
411
|
-
expect(decision1).not.toBeNull();
|
|
412
|
-
expect(decision1!.decision).toBe('allow');
|
|
413
|
-
|
|
414
|
-
// Second invocation — grant already consumed, should deny
|
|
415
|
-
const mockData2 = createMockSession({ confirmationRequestId: 'req-second' });
|
|
416
|
-
setupBridgeDeps(() => mockData2.session);
|
|
417
|
-
|
|
418
|
-
await startVoiceTurn({
|
|
419
|
-
conversationId: CONVERSATION_ID,
|
|
420
|
-
callSessionId: CALL_SESSION_ID,
|
|
421
|
-
content: 'second utterance',
|
|
417
|
+
content: 'test utterance',
|
|
422
418
|
assistantId: ASSISTANT_ID,
|
|
423
419
|
guardianContext,
|
|
424
420
|
isInbound: true,
|
|
@@ -429,9 +425,9 @@ describe('voice scoped grant consumer', () => {
|
|
|
429
425
|
|
|
430
426
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
431
427
|
|
|
432
|
-
const
|
|
433
|
-
expect(
|
|
434
|
-
expect(
|
|
428
|
+
const decision = mockData.getConfirmationDecision();
|
|
429
|
+
expect(decision).not.toBeNull();
|
|
430
|
+
expect(decision!.decision).toBe('deny');
|
|
435
431
|
});
|
|
436
432
|
|
|
437
433
|
test('grants revoked when revokeScopedApprovalGrantsForContext is called with callSessionId', () => {
|
|
@@ -534,38 +530,4 @@ describe('voice scoped grant consumer', () => {
|
|
|
534
530
|
.all();
|
|
535
531
|
expect(otherActive.length).toBe(1);
|
|
536
532
|
});
|
|
537
|
-
|
|
538
|
-
test('non-guardian with grant for different assistantId: auto-denied', async () => {
|
|
539
|
-
// Create a grant scoped to a different assistant
|
|
540
|
-
createScopedApprovalGrant(grantParams({
|
|
541
|
-
assistantId: 'other-assistant',
|
|
542
|
-
}));
|
|
543
|
-
|
|
544
|
-
const mockData = createMockSession();
|
|
545
|
-
setupBridgeDeps(() => mockData.session);
|
|
546
|
-
|
|
547
|
-
const guardianContext: GuardianRuntimeContext = {
|
|
548
|
-
sourceChannel: 'voice',
|
|
549
|
-
actorRole: 'non-guardian',
|
|
550
|
-
requesterExternalUserId: 'caller-123',
|
|
551
|
-
};
|
|
552
|
-
|
|
553
|
-
await startVoiceTurn({
|
|
554
|
-
conversationId: CONVERSATION_ID,
|
|
555
|
-
callSessionId: CALL_SESSION_ID,
|
|
556
|
-
content: 'test utterance',
|
|
557
|
-
assistantId: ASSISTANT_ID,
|
|
558
|
-
guardianContext,
|
|
559
|
-
isInbound: true,
|
|
560
|
-
onTextDelta: () => {},
|
|
561
|
-
onComplete: () => {},
|
|
562
|
-
onError: () => {},
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
566
|
-
|
|
567
|
-
const decision = mockData.getConfirmationDecision();
|
|
568
|
-
expect(decision).not.toBeNull();
|
|
569
|
-
expect(decision!.decision).toBe('deny');
|
|
570
|
-
});
|
|
571
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
|
|