@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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handlers for deterministic guardian action endpoints.
|
|
3
|
+
*
|
|
4
|
+
* These endpoints let desktop clients fetch pending guardian prompts and
|
|
5
|
+
* submit button decisions without relying on text parsing.
|
|
6
|
+
*/
|
|
7
|
+
import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
|
|
8
|
+
import {
|
|
9
|
+
getPendingApprovalForRequest,
|
|
10
|
+
listPendingApprovalRequests,
|
|
11
|
+
} from '../../memory/channel-guardian-store.js';
|
|
12
|
+
import type { ApprovalAction } from '../channel-approval-types.js';
|
|
13
|
+
import { handleChannelDecision } from '../channel-approvals.js';
|
|
14
|
+
import type { GuardianDecisionPrompt } from '../guardian-decision-types.js';
|
|
15
|
+
import { buildDecisionActions } from '../guardian-decision-types.js';
|
|
16
|
+
import { httpError } from '../http-errors.js';
|
|
17
|
+
import * as pendingInteractions from '../pending-interactions.js';
|
|
18
|
+
import { handleAccessRequestDecision } from './access-request-decision.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// GET /v1/guardian-actions/pending?conversationId=...
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List pending guardian decision prompts for a conversation.
|
|
26
|
+
*
|
|
27
|
+
* Returns guardian approval requests (from the channel guardian store) that
|
|
28
|
+
* are still pending, mapped to the GuardianDecisionPrompt shape so clients
|
|
29
|
+
* can render structured button UIs.
|
|
30
|
+
*/
|
|
31
|
+
export function handleGuardianActionsPending(req: Request): Response {
|
|
32
|
+
const url = new URL(req.url);
|
|
33
|
+
const conversationId = url.searchParams.get('conversationId');
|
|
34
|
+
|
|
35
|
+
if (!conversationId) {
|
|
36
|
+
return httpError('BAD_REQUEST', 'conversationId query parameter is required', 400);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const prompts = listGuardianDecisionPrompts({ conversationId });
|
|
40
|
+
return Response.json({ conversationId, prompts });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// POST /v1/guardian-actions/decision
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Submit a guardian action decision.
|
|
49
|
+
*
|
|
50
|
+
* Looks up the guardian approval by requestId and applies the decision
|
|
51
|
+
* through the unified guardian decision primitive.
|
|
52
|
+
*/
|
|
53
|
+
export async function handleGuardianActionDecision(req: Request): Promise<Response> {
|
|
54
|
+
const body = await req.json() as {
|
|
55
|
+
requestId?: string;
|
|
56
|
+
action?: string;
|
|
57
|
+
conversationId?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const { requestId, action, conversationId } = body;
|
|
61
|
+
|
|
62
|
+
if (!requestId || typeof requestId !== 'string') {
|
|
63
|
+
return httpError('BAD_REQUEST', 'requestId is required', 400);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!action || typeof action !== 'string') {
|
|
67
|
+
return httpError('BAD_REQUEST', 'action is required', 400);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const VALID_ACTIONS = new Set<string>(['approve_once', 'approve_always', 'reject']);
|
|
71
|
+
if (!VALID_ACTIONS.has(action)) {
|
|
72
|
+
return httpError('BAD_REQUEST', `Invalid action: ${action}. Must be one of: approve_once, approve_always, reject`, 400);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Try the channel guardian approval store first (tool approval prompts)
|
|
76
|
+
const approval = getPendingApprovalForRequest(requestId);
|
|
77
|
+
if (approval) {
|
|
78
|
+
// Enforce conversationId scoping: reject decisions that target the wrong conversation.
|
|
79
|
+
if (conversationId && conversationId !== approval.conversationId) {
|
|
80
|
+
return httpError('BAD_REQUEST', 'conversationId does not match the approval', 400);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Access request approvals need a separate decision path — they don't have
|
|
84
|
+
// pending interactions and use verification sessions instead.
|
|
85
|
+
if (approval.toolName === 'ingress_access_request') {
|
|
86
|
+
const mappedAction = action === 'reject' ? 'deny' as const : 'approve' as const;
|
|
87
|
+
// Use 'desktop' as the actor identity because this endpoint is
|
|
88
|
+
// unauthenticated — we cannot verify the caller is the assigned
|
|
89
|
+
// guardian, so we record a generic desktop origin instead of
|
|
90
|
+
// falsely attributing the decision to guardianExternalUserId.
|
|
91
|
+
const decisionResult = handleAccessRequestDecision(
|
|
92
|
+
approval,
|
|
93
|
+
mappedAction,
|
|
94
|
+
'desktop',
|
|
95
|
+
);
|
|
96
|
+
return Response.json({
|
|
97
|
+
applied: decisionResult.type !== 'stale',
|
|
98
|
+
requestId,
|
|
99
|
+
reason: decisionResult.type === 'stale' ? 'stale' : undefined,
|
|
100
|
+
accessRequestResult: decisionResult,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Note: actorExternalUserId is left undefined because the desktop endpoint
|
|
105
|
+
// does not authenticate caller identity. This means scoped grant minting is
|
|
106
|
+
// skipped for button-based decisions — an acceptable trade-off to avoid
|
|
107
|
+
// falsifying audit records with an unverified guardian identity.
|
|
108
|
+
const result = applyGuardianDecision({
|
|
109
|
+
approval,
|
|
110
|
+
decision: { action: action as 'approve_once' | 'approve_always' | 'reject', source: 'plain_text', requestId },
|
|
111
|
+
actorExternalUserId: undefined,
|
|
112
|
+
actorChannel: 'vellum',
|
|
113
|
+
});
|
|
114
|
+
return Response.json({ ...result, requestId: result.requestId ?? requestId });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fall back to the pending interactions tracker (direct confirmation requests).
|
|
118
|
+
// Route through handleChannelDecision so approve_always properly persists trust rules.
|
|
119
|
+
const interaction = pendingInteractions.get(requestId);
|
|
120
|
+
if (interaction) {
|
|
121
|
+
// Enforce conversationId scoping for interactions too.
|
|
122
|
+
if (conversationId && conversationId !== interaction.conversationId) {
|
|
123
|
+
return httpError('BAD_REQUEST', 'conversationId does not match the interaction', 400);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = handleChannelDecision(
|
|
127
|
+
interaction.conversationId,
|
|
128
|
+
{ action: action as ApprovalAction, source: 'plain_text', requestId },
|
|
129
|
+
);
|
|
130
|
+
return Response.json({ ...result, requestId: result.requestId ?? requestId });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return httpError('NOT_FOUND', 'No pending guardian action found for this requestId', 404);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Shared helper: list guardian decision prompts
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a list of GuardianDecisionPrompt objects for the given conversation.
|
|
142
|
+
*
|
|
143
|
+
* Aggregates pending guardian approval requests from the channel guardian
|
|
144
|
+
* store and pending confirmation interactions from the pending-interactions
|
|
145
|
+
* tracker, exposing them in a uniform shape that clients can render as
|
|
146
|
+
* structured button UIs.
|
|
147
|
+
*/
|
|
148
|
+
export function listGuardianDecisionPrompts(params: {
|
|
149
|
+
conversationId: string;
|
|
150
|
+
}): GuardianDecisionPrompt[] {
|
|
151
|
+
const { conversationId } = params;
|
|
152
|
+
const prompts: GuardianDecisionPrompt[] = [];
|
|
153
|
+
|
|
154
|
+
// 1. Channel guardian approval requests (tool approvals routed to guardians)
|
|
155
|
+
const approvalRequests = listPendingApprovalRequests({
|
|
156
|
+
conversationId,
|
|
157
|
+
status: 'pending',
|
|
158
|
+
}).filter(a => a.expiresAt > Date.now() && a.requestId != null);
|
|
159
|
+
|
|
160
|
+
for (const approval of approvalRequests) {
|
|
161
|
+
const reqId = approval.requestId!;
|
|
162
|
+
prompts.push({
|
|
163
|
+
requestId: reqId,
|
|
164
|
+
requestCode: reqId.slice(0, 6).toUpperCase(),
|
|
165
|
+
state: 'pending',
|
|
166
|
+
questionText: approval.reason ?? `Approve tool: ${approval.toolName ?? 'unknown'}`,
|
|
167
|
+
toolName: approval.toolName ?? null,
|
|
168
|
+
actions: buildDecisionActions({ forGuardianOnBehalf: true }),
|
|
169
|
+
expiresAt: approval.expiresAt,
|
|
170
|
+
conversationId: approval.conversationId,
|
|
171
|
+
callSessionId: null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 2. Guardian action requests (voice call guardian questions) are intentionally
|
|
176
|
+
// excluded here — resolving them requires the answerCall + resolveGuardianActionRequest
|
|
177
|
+
// flow which is handled by the conversational session-process path, not by the
|
|
178
|
+
// deterministic button decision endpoint.
|
|
179
|
+
// TODO: Surface voice guardian-action requests as read-only informational prompts
|
|
180
|
+
// so desktop clients can see them even though they can't be resolved via buttons.
|
|
181
|
+
|
|
182
|
+
// 3. Pending confirmation interactions (direct tool approval prompts)
|
|
183
|
+
const interactions = pendingInteractions.getByConversation(conversationId);
|
|
184
|
+
for (const interaction of interactions) {
|
|
185
|
+
if (interaction.kind !== 'confirmation' || !interaction.confirmationDetails) continue;
|
|
186
|
+
// Skip if already covered by a channel guardian approval above
|
|
187
|
+
if (prompts.some(p => p.requestId === interaction.requestId)) continue;
|
|
188
|
+
|
|
189
|
+
const details = interaction.confirmationDetails;
|
|
190
|
+
prompts.push({
|
|
191
|
+
requestId: interaction.requestId,
|
|
192
|
+
requestCode: interaction.requestId.slice(0, 6).toUpperCase(),
|
|
193
|
+
state: 'pending',
|
|
194
|
+
questionText: `Approve tool: ${details.toolName}`,
|
|
195
|
+
toolName: details.toolName,
|
|
196
|
+
actions: buildDecisionActions({
|
|
197
|
+
persistentDecisionsAllowed: details.persistentDecisionsAllowed,
|
|
198
|
+
}),
|
|
199
|
+
expiresAt: Date.now() + 300_000,
|
|
200
|
+
conversationId,
|
|
201
|
+
callSessionId: null,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return prompts;
|
|
206
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Approval interception: checks for pending approvals and handles inbound
|
|
3
3
|
* messages as decisions, reminders, or conversational follow-ups.
|
|
4
4
|
*/
|
|
5
|
+
import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
|
|
5
6
|
import type { ChannelId } from '../../channels/types.js';
|
|
6
7
|
import {
|
|
7
8
|
getAllPendingApprovalsByGuardianChat,
|
|
@@ -11,9 +12,7 @@ import {
|
|
|
11
12
|
type GuardianApprovalRequest,
|
|
12
13
|
updateApprovalDecision,
|
|
13
14
|
} from '../../memory/channel-guardian-store.js';
|
|
14
|
-
import { createScopedApprovalGrant } from '../../memory/scoped-approval-grants.js';
|
|
15
15
|
import { emitNotificationSignal } from '../../notifications/emit-signal.js';
|
|
16
|
-
import { computeToolApprovalDigest } from '../../security/tool-approval-digest.js';
|
|
17
16
|
import { getLogger } from '../../util/logger.js';
|
|
18
17
|
import { runApprovalConversationTurn } from '../approval-conversation-turn.js';
|
|
19
18
|
import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
|
|
@@ -25,7 +24,6 @@ import {
|
|
|
25
24
|
getApprovalInfoByConversation,
|
|
26
25
|
getChannelApprovalPrompt,
|
|
27
26
|
handleChannelDecision,
|
|
28
|
-
type PendingApprovalInfo,
|
|
29
27
|
} from '../channel-approvals.js';
|
|
30
28
|
import { deliverChannelReply } from '../gateway-client.js';
|
|
31
29
|
import type {
|
|
@@ -49,68 +47,6 @@ import {
|
|
|
49
47
|
|
|
50
48
|
const log = getLogger('runtime-http');
|
|
51
49
|
|
|
52
|
-
/** TTL for scoped approval grants minted on guardian approve_once decisions. */
|
|
53
|
-
export const GRANT_TTL_MS = 5 * 60 * 1000;
|
|
54
|
-
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Scoped grant minting on guardian tool-approval decisions
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Mint a `tool_signature` scoped grant when a guardian approves a tool-approval
|
|
61
|
-
* request. Only mints when the approval info contains a tool invocation with
|
|
62
|
-
* input (so we can compute the input digest). Informational ASK_GUARDIAN
|
|
63
|
-
* requests that lack tool input are skipped.
|
|
64
|
-
*
|
|
65
|
-
* Fails silently on error — grant minting is best-effort and must never block
|
|
66
|
-
* the approval flow.
|
|
67
|
-
*/
|
|
68
|
-
function tryMintToolApprovalGrant(params: {
|
|
69
|
-
approvalInfo: PendingApprovalInfo;
|
|
70
|
-
approval: GuardianApprovalRequest;
|
|
71
|
-
decisionChannel: ChannelId;
|
|
72
|
-
guardianExternalUserId: string;
|
|
73
|
-
}): void {
|
|
74
|
-
const { approvalInfo, approval, decisionChannel, guardianExternalUserId } = params;
|
|
75
|
-
|
|
76
|
-
// Only mint for requests that carry a tool name — the presence of toolName
|
|
77
|
-
// distinguishes tool-approval requests from informational ones.
|
|
78
|
-
// computeToolApprovalDigest can deterministically hash {} so zero-argument
|
|
79
|
-
// tool invocations must still receive a grant.
|
|
80
|
-
if (!approvalInfo.toolName) {
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
const inputDigest = computeToolApprovalDigest(approvalInfo.toolName, approvalInfo.input);
|
|
86
|
-
|
|
87
|
-
createScopedApprovalGrant({
|
|
88
|
-
assistantId: approval.assistantId,
|
|
89
|
-
scopeMode: 'tool_signature',
|
|
90
|
-
toolName: approvalInfo.toolName,
|
|
91
|
-
inputDigest,
|
|
92
|
-
requestChannel: approval.channel,
|
|
93
|
-
decisionChannel,
|
|
94
|
-
executionChannel: null,
|
|
95
|
-
conversationId: approval.conversationId,
|
|
96
|
-
callSessionId: null,
|
|
97
|
-
guardianExternalUserId,
|
|
98
|
-
requesterExternalUserId: approval.requesterExternalUserId,
|
|
99
|
-
expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
log.info(
|
|
103
|
-
{ toolName: approvalInfo.toolName, conversationId: approval.conversationId },
|
|
104
|
-
'Minted scoped approval grant for guardian tool-approval decision',
|
|
105
|
-
);
|
|
106
|
-
} catch (err) {
|
|
107
|
-
log.error(
|
|
108
|
-
{ err, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
|
|
109
|
-
'Failed to mint scoped approval grant (non-fatal)',
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
50
|
export interface ApprovalInterceptionParams {
|
|
115
51
|
conversationId: string;
|
|
116
52
|
callbackData?: string;
|
|
@@ -250,13 +186,6 @@ export async function handleApprovalInterception(
|
|
|
250
186
|
}
|
|
251
187
|
|
|
252
188
|
if (callbackDecision) {
|
|
253
|
-
// approve_always is not available for guardian approvals — guardians
|
|
254
|
-
// should not be able to permanently allowlist tools on behalf of the
|
|
255
|
-
// requester. Downgrade to approve_once.
|
|
256
|
-
if (callbackDecision.action === 'approve_always') {
|
|
257
|
-
callbackDecision = { ...callbackDecision, action: 'approve_once' };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
189
|
// Access request approvals don't have a pending interaction in the
|
|
261
190
|
// session tracker, so they need a separate decision path that creates
|
|
262
191
|
// a verification session instead of resuming an agent loop.
|
|
@@ -272,44 +201,22 @@ export async function handleApprovalInterception(
|
|
|
272
201
|
return accessResult;
|
|
273
202
|
}
|
|
274
203
|
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const result = handleChannelDecision(
|
|
285
|
-
guardianApproval.conversationId,
|
|
286
|
-
callbackDecision,
|
|
287
|
-
);
|
|
204
|
+
// Apply the decision through the unified guardian decision primitive.
|
|
205
|
+
// The primitive handles approve_always downgrade, approval info capture,
|
|
206
|
+
// record update, and scoped grant minting.
|
|
207
|
+
const result = applyGuardianDecision({
|
|
208
|
+
approval: guardianApproval,
|
|
209
|
+
decision: callbackDecision,
|
|
210
|
+
actorExternalUserId: senderExternalUserId,
|
|
211
|
+
actorChannel: sourceChannel,
|
|
212
|
+
});
|
|
288
213
|
|
|
289
214
|
if (result.applied) {
|
|
290
|
-
// Update the guardian approval request record only when the decision
|
|
291
|
-
// was actually applied. If the request was already resolved (race with
|
|
292
|
-
// expiry sweep or concurrent callback), skip to avoid inconsistency.
|
|
293
|
-
const approvalStatus = callbackDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
|
|
294
|
-
updateApprovalDecision(guardianApproval.id, {
|
|
295
|
-
status: approvalStatus,
|
|
296
|
-
decidedByExternalUserId: senderExternalUserId,
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// Mint a scoped grant when a guardian approves a tool-approval request
|
|
300
|
-
if (callbackDecision.action !== 'reject' && cbMatchedInfo) {
|
|
301
|
-
tryMintToolApprovalGrant({
|
|
302
|
-
approvalInfo: cbMatchedInfo,
|
|
303
|
-
approval: guardianApproval,
|
|
304
|
-
decisionChannel: sourceChannel,
|
|
305
|
-
guardianExternalUserId: senderExternalUserId,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
215
|
// Notify the requester's chat about the outcome with the tool name
|
|
216
|
+
const effectiveAction = callbackDecision.action === 'approve_always' ? 'approve_once' : callbackDecision.action;
|
|
310
217
|
const outcomeText = await composeApprovalMessageGenerative({
|
|
311
218
|
scenario: 'guardian_decision_outcome',
|
|
312
|
-
decision:
|
|
219
|
+
decision: effectiveAction === 'reject' ? 'denied' : 'approved',
|
|
313
220
|
toolName: guardianApproval.toolName,
|
|
314
221
|
channel: sourceChannel,
|
|
315
222
|
}, {}, approvalCopyGenerator);
|
|
@@ -428,38 +335,15 @@ export async function handleApprovalInterception(
|
|
|
428
335
|
...(engineResult.targetRequestId ? { requestId: engineResult.targetRequestId } : {}),
|
|
429
336
|
};
|
|
430
337
|
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
:
|
|
437
|
-
|
|
438
|
-
const result = handleChannelDecision(
|
|
439
|
-
targetApproval.conversationId,
|
|
440
|
-
engineDecision,
|
|
441
|
-
);
|
|
338
|
+
// Apply the decision through the unified guardian decision primitive.
|
|
339
|
+
const result = applyGuardianDecision({
|
|
340
|
+
approval: targetApproval,
|
|
341
|
+
decision: engineDecision,
|
|
342
|
+
actorExternalUserId: senderExternalUserId,
|
|
343
|
+
actorChannel: sourceChannel,
|
|
344
|
+
});
|
|
442
345
|
|
|
443
346
|
if (result.applied) {
|
|
444
|
-
// Update the guardian approval request record only when the decision
|
|
445
|
-
// was actually applied. If the request was already resolved (race with
|
|
446
|
-
// expiry sweep or concurrent callback), skip to avoid inconsistency.
|
|
447
|
-
const approvalStatus = decisionAction === 'reject' ? 'denied' as const : 'approved' as const;
|
|
448
|
-
updateApprovalDecision(targetApproval.id, {
|
|
449
|
-
status: approvalStatus,
|
|
450
|
-
decidedByExternalUserId: senderExternalUserId,
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// Mint a scoped grant when a guardian approves a tool-approval request
|
|
454
|
-
if (decisionAction !== 'reject' && engineMatchedInfo) {
|
|
455
|
-
tryMintToolApprovalGrant({
|
|
456
|
-
approvalInfo: engineMatchedInfo,
|
|
457
|
-
approval: targetApproval,
|
|
458
|
-
decisionChannel: sourceChannel,
|
|
459
|
-
guardianExternalUserId: senderExternalUserId,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
|
|
463
347
|
// Notify the requester's chat about the outcome
|
|
464
348
|
const outcomeText = await composeApprovalMessageGenerative({
|
|
465
349
|
scenario: 'guardian_decision_outcome',
|
|
@@ -591,35 +475,15 @@ export async function handleApprovalInterception(
|
|
|
591
475
|
return accessResult;
|
|
592
476
|
}
|
|
593
477
|
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
:
|
|
600
|
-
|
|
601
|
-
const result = handleChannelDecision(
|
|
602
|
-
targetLegacyApproval.conversationId,
|
|
603
|
-
legacyGuardianDecision,
|
|
604
|
-
);
|
|
478
|
+
// Apply the decision through the unified guardian decision primitive.
|
|
479
|
+
const result = applyGuardianDecision({
|
|
480
|
+
approval: targetLegacyApproval,
|
|
481
|
+
decision: legacyGuardianDecision,
|
|
482
|
+
actorExternalUserId: senderExternalUserId,
|
|
483
|
+
actorChannel: sourceChannel,
|
|
484
|
+
});
|
|
605
485
|
|
|
606
486
|
if (result.applied) {
|
|
607
|
-
const approvalStatus = legacyGuardianDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
|
|
608
|
-
updateApprovalDecision(targetLegacyApproval.id, {
|
|
609
|
-
status: approvalStatus,
|
|
610
|
-
decidedByExternalUserId: senderExternalUserId,
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
// Mint a scoped grant when a guardian approves a tool-approval request
|
|
614
|
-
if (legacyGuardianDecision.action !== 'reject' && legacyMatchedInfo) {
|
|
615
|
-
tryMintToolApprovalGrant({
|
|
616
|
-
approvalInfo: legacyMatchedInfo,
|
|
617
|
-
approval: targetLegacyApproval,
|
|
618
|
-
decisionChannel: sourceChannel,
|
|
619
|
-
guardianExternalUserId: senderExternalUserId,
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
|
|
623
487
|
// Notify the requester's chat about the outcome
|
|
624
488
|
const outcomeText = await composeApprovalMessageGenerative({
|
|
625
489
|
scenario: 'guardian_decision_outcome',
|
|
@@ -742,13 +606,15 @@ export async function handleApprovalInterception(
|
|
|
742
606
|
action: 'reject',
|
|
743
607
|
source: 'plain_text',
|
|
744
608
|
};
|
|
745
|
-
|
|
609
|
+
// Apply the cancel decision through the unified primitive.
|
|
610
|
+
// The primitive handles record update and (no-op) grant logic.
|
|
611
|
+
const cancelApplyResult = applyGuardianDecision({
|
|
612
|
+
approval: guardianApprovalForRequest,
|
|
613
|
+
decision: rejectDecision,
|
|
614
|
+
actorExternalUserId: senderExternalUserId,
|
|
615
|
+
actorChannel: sourceChannel,
|
|
616
|
+
});
|
|
746
617
|
if (cancelApplyResult.applied) {
|
|
747
|
-
updateApprovalDecision(guardianApprovalForRequest.id, {
|
|
748
|
-
status: 'denied',
|
|
749
|
-
decidedByExternalUserId: senderExternalUserId,
|
|
750
|
-
});
|
|
751
|
-
|
|
752
618
|
// Notify requester
|
|
753
619
|
const replyText = cancelReplyText ?? await composeApprovalMessageGenerative({
|
|
754
620
|
scenario: 'requester_cancel',
|
|
@@ -1152,29 +1018,39 @@ async function handleAccessRequestApproval(
|
|
|
1152
1018
|
});
|
|
1153
1019
|
}
|
|
1154
1020
|
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
isAsyncBackground: false,
|
|
1165
|
-
visibleInSourceNow: false,
|
|
1166
|
-
},
|
|
1167
|
-
contextPayload: {
|
|
1021
|
+
// Don't emit guardian_decision for approvals that still require code
|
|
1022
|
+
// verification — the guardian already received the code, and emitting
|
|
1023
|
+
// this signal prematurely causes the notification pipeline to deliver
|
|
1024
|
+
// a confusing "approved" message before the requester has verified.
|
|
1025
|
+
// The guardian_decision signal should only fire once access is fully granted
|
|
1026
|
+
// (i.e. after code consumption), which is handled in the verification path.
|
|
1027
|
+
if (!decisionResult.verificationSessionId) {
|
|
1028
|
+
void emitNotificationSignal({
|
|
1029
|
+
sourceEventName: 'ingress.trusted_contact.guardian_decision',
|
|
1168
1030
|
sourceChannel: approval.channel,
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1031
|
+
sourceSessionId: approval.conversationId,
|
|
1032
|
+
assistantId,
|
|
1033
|
+
attentionHints: {
|
|
1034
|
+
requiresAction: false,
|
|
1035
|
+
urgency: 'medium',
|
|
1036
|
+
isAsyncBackground: false,
|
|
1037
|
+
visibleInSourceNow: false,
|
|
1038
|
+
},
|
|
1039
|
+
contextPayload: {
|
|
1040
|
+
sourceChannel: approval.channel,
|
|
1041
|
+
requesterExternalUserId: approval.requesterExternalUserId,
|
|
1042
|
+
requesterChatId: approval.requesterChatId,
|
|
1043
|
+
decidedByExternalUserId,
|
|
1044
|
+
decision: 'approved',
|
|
1045
|
+
},
|
|
1046
|
+
dedupeKey: `trusted-contact:guardian-decision:${approval.id}`,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1176
1049
|
|
|
1177
|
-
//
|
|
1050
|
+
// Emit verification_sent with visibleInSourceNow=true so the notification
|
|
1051
|
+
// pipeline suppresses delivery — the guardian already received the
|
|
1052
|
+
// verification code directly. Without this flag, the pipeline generates
|
|
1053
|
+
// a redundant LLM message like "Good news! Your request has been approved."
|
|
1178
1054
|
if (decisionResult.verificationSessionId && codeDelivered) {
|
|
1179
1055
|
void emitNotificationSignal({
|
|
1180
1056
|
sourceEventName: 'ingress.trusted_contact.verification_sent',
|
|
@@ -1185,7 +1061,7 @@ async function handleAccessRequestApproval(
|
|
|
1185
1061
|
requiresAction: false,
|
|
1186
1062
|
urgency: 'low',
|
|
1187
1063
|
isAsyncBackground: true,
|
|
1188
|
-
visibleInSourceNow:
|
|
1064
|
+
visibleInSourceNow: true,
|
|
1189
1065
|
},
|
|
1190
1066
|
contextPayload: {
|
|
1191
1067
|
sourceChannel: approval.channel,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync, statfsSync,statSync } from 'node:fs';
|
|
6
|
+
import { cpus, totalmem } from 'node:os';
|
|
6
7
|
import { dirname,join } from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
9
|
|
|
@@ -36,6 +37,76 @@ function getDiskSpaceInfo(): DiskSpaceInfo | null {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
interface MemoryInfo {
|
|
41
|
+
currentMb: number;
|
|
42
|
+
maxMb: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read the container memory limit from cgroups if available, falling back to host total.
|
|
46
|
+
// cgroups v2: /sys/fs/cgroup/memory.max (returns "max" when unlimited)
|
|
47
|
+
// cgroups v1: /sys/fs/cgroup/memory/memory.limit_in_bytes (large sentinel when unlimited)
|
|
48
|
+
function getContainerMemoryLimitBytes(): number | null {
|
|
49
|
+
try {
|
|
50
|
+
const v2 = readFileSync('/sys/fs/cgroup/memory.max', 'utf-8').trim();
|
|
51
|
+
if (v2 !== 'max') {
|
|
52
|
+
const bytes = parseInt(v2, 10);
|
|
53
|
+
if (!isNaN(bytes) && bytes > 0) return bytes;
|
|
54
|
+
}
|
|
55
|
+
} catch { /* not available */ }
|
|
56
|
+
try {
|
|
57
|
+
const v1 = readFileSync('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf-8').trim();
|
|
58
|
+
const bytes = parseInt(v1, 10);
|
|
59
|
+
// cgroups v1 uses a near-INT64_MAX sentinel when no limit is set
|
|
60
|
+
if (!isNaN(bytes) && bytes > 0 && bytes < totalmem() * 1.5) return bytes;
|
|
61
|
+
} catch { /* not available */ }
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getMemoryInfo(): MemoryInfo {
|
|
66
|
+
const bytesToMb = (b: number) => Math.round((b / (1024 * 1024)) * 100) / 100;
|
|
67
|
+
return {
|
|
68
|
+
currentMb: bytesToMb(process.memoryUsage().rss),
|
|
69
|
+
maxMb: bytesToMb(getContainerMemoryLimitBytes() ?? totalmem()),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface CpuInfo {
|
|
74
|
+
currentPercent: number;
|
|
75
|
+
maxCores: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Track CPU usage over a rolling window so /healthz reports near-real-time
|
|
79
|
+
// utilization instead of a lifetime average (total CPU time / total uptime).
|
|
80
|
+
const CPU_SAMPLE_INTERVAL_MS = 5_000;
|
|
81
|
+
let _lastCpuUsage: NodeJS.CpuUsage = process.cpuUsage();
|
|
82
|
+
let _lastCpuTime: number = Date.now();
|
|
83
|
+
let _cachedCpuPercent = 0;
|
|
84
|
+
|
|
85
|
+
// Kick off the background sampler. unref() so it never prevents process exit.
|
|
86
|
+
setInterval(() => {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const newUsage = process.cpuUsage();
|
|
89
|
+
const elapsedMs = now - _lastCpuTime;
|
|
90
|
+
if (elapsedMs > 0) {
|
|
91
|
+
const deltaCpuUs =
|
|
92
|
+
(newUsage.user - _lastCpuUsage.user) +
|
|
93
|
+
(newUsage.system - _lastCpuUsage.system);
|
|
94
|
+
const deltaCpuMs = deltaCpuUs / 1000;
|
|
95
|
+
const numCores = cpus().length;
|
|
96
|
+
_cachedCpuPercent =
|
|
97
|
+
Math.round((deltaCpuMs / (elapsedMs * numCores)) * 10000) / 100;
|
|
98
|
+
}
|
|
99
|
+
_lastCpuUsage = newUsage;
|
|
100
|
+
_lastCpuTime = now;
|
|
101
|
+
}, CPU_SAMPLE_INTERVAL_MS).unref();
|
|
102
|
+
|
|
103
|
+
function getCpuInfo(): CpuInfo {
|
|
104
|
+
return {
|
|
105
|
+
currentPercent: _cachedCpuPercent,
|
|
106
|
+
maxCores: cpus().length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
39
110
|
function getPackageVersion(): string | undefined {
|
|
40
111
|
try {
|
|
41
112
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json');
|
|
@@ -52,6 +123,8 @@ export function handleHealth(): Response {
|
|
|
52
123
|
timestamp: new Date().toISOString(),
|
|
53
124
|
version: getPackageVersion(),
|
|
54
125
|
disk: getDiskSpaceInfo(),
|
|
126
|
+
memory: getMemoryInfo(),
|
|
127
|
+
cpu: getCpuInfo(),
|
|
55
128
|
});
|
|
56
129
|
}
|
|
57
130
|
|