@vellumai/assistant 0.4.35 → 0.4.37
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/AGENTS.md +1 -1
- package/ARCHITECTURE.md +44 -49
- package/README.md +32 -20
- package/docs/architecture/keychain-broker.md +186 -0
- package/docs/architecture/security.md +110 -116
- package/docs/runbook-trusted-contacts.md +2 -2
- package/docs/skills.md +25 -25
- package/package.json +5 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
- package/src/__tests__/actor-token-service.test.ts +1 -0
- package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
- package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
- package/src/__tests__/browser-fill-credential.test.ts +1 -1
- package/src/__tests__/bundle-scanner.test.ts +1 -1
- package/src/__tests__/channel-guardian.test.ts +102 -102
- package/src/__tests__/channel-invite-transport.test.ts +155 -256
- package/src/__tests__/channel-readiness-routes.test.ts +336 -0
- package/src/__tests__/checker.test.ts +6 -6
- package/src/__tests__/chrome-cdp.test.ts +350 -0
- package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
- package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
- package/src/__tests__/config-loader-migration.test.ts +85 -0
- package/src/__tests__/conversation-pairing.test.ts +370 -5
- package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
- package/src/__tests__/credential-broker-server-use.test.ts +1 -10
- package/src/__tests__/credential-security-e2e.test.ts +7 -1
- package/src/__tests__/credential-security-invariants.test.ts +14 -20
- package/src/__tests__/credential-vault-unit.test.ts +1 -11
- package/src/__tests__/credential-vault.test.ts +5 -19
- package/src/__tests__/credentials-cli.test.ts +814 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
- package/src/__tests__/email-invite-adapter.test.ts +78 -0
- package/src/__tests__/email-service-config-fallback.test.ts +102 -0
- package/src/__tests__/encrypted-store.test.ts +6 -6
- package/src/__tests__/ephemeral-permissions.test.ts +3 -3
- package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
- package/src/__tests__/guardian-outbound-http.test.ts +53 -47
- package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
- package/src/__tests__/handlers-telegram-config.test.ts +8 -2
- package/src/__tests__/handlers-twitter-config.test.ts +2 -2
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
- package/src/__tests__/ingress-reconcile.test.ts +6 -0
- package/src/__tests__/intent-routing.test.ts +23 -4
- package/src/__tests__/invite-routes-http.test.ts +12 -0
- package/src/__tests__/ipc-snapshot.test.ts +8 -2
- package/src/__tests__/keychain-broker-client.test.ts +543 -0
- package/src/__tests__/llm-usage-store.test.ts +344 -0
- package/src/__tests__/mcp-client-auth.test.ts +2 -2
- package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
- package/src/__tests__/migration-transport.test.ts +49 -0
- package/src/__tests__/notification-broadcaster.test.ts +205 -5
- package/src/__tests__/notification-deep-link.test.ts +365 -1
- package/src/__tests__/oauth-connect-handler.test.ts +2 -2
- package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
- package/src/__tests__/proxy-approval-callback.test.ts +1 -1
- package/src/__tests__/recording-handler.test.ts +1 -1
- package/src/__tests__/recording-intent-handler.test.ts +6 -1
- package/src/__tests__/recording-state-machine.test.ts +1 -1
- package/src/__tests__/relay-server.test.ts +9 -1
- package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
- package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
- package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +8 -2
- package/src/__tests__/secure-keys.test.ts +175 -216
- package/src/__tests__/session-confirmation-signals.test.ts +1 -1
- package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
- package/src/__tests__/session-queue.test.ts +2 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
- package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
- package/src/__tests__/skill-feature-flags.test.ts +12 -9
- package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
- package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
- package/src/__tests__/skills.test.ts +34 -4
- package/src/__tests__/slack-channel-config.test.ts +2 -2
- package/src/__tests__/system-prompt.test.ts +26 -4
- package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
- package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
- package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
- package/src/__tests__/twitter-auth-handler.test.ts +2 -2
- package/src/__tests__/twitter-oauth-client.test.ts +1 -1
- package/src/__tests__/usage-routes.test.ts +339 -0
- package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
- package/src/agent/loop.ts +3 -0
- package/src/amazon/checkout.ts +0 -1
- package/src/approvals/guardian-request-resolvers.ts +9 -1
- package/src/bundler/app-bundler.ts +28 -12
- package/src/bundler/bundle-scanner.ts +1 -1
- package/src/bundler/bundle-signer.ts +3 -3
- package/src/bundler/manifest.ts +1 -1
- package/src/bundler/signature-verifier.ts +3 -3
- package/src/channels/config.ts +1 -1
- package/src/cli/AGENTS.md +63 -0
- package/src/cli/__tests__/notifications.test.ts +470 -0
- package/src/cli/amazon.ts +344 -167
- package/src/cli/audit.ts +85 -0
- package/src/cli/autonomy.ts +369 -0
- package/src/cli/channels.ts +51 -0
- package/src/cli/completions.ts +208 -0
- package/src/cli/config.ts +220 -0
- package/src/cli/contacts.ts +471 -0
- package/src/cli/credentials.ts +564 -0
- package/src/cli/default-action.ts +14 -0
- package/src/cli/dev.ts +131 -0
- package/src/cli/doctor.ts +398 -0
- package/src/cli/email.ts +494 -0
- package/src/cli/influencer.ts +72 -0
- package/src/cli/integrations.ts +248 -57
- package/src/cli/keys.ts +114 -0
- package/src/cli/map.ts +46 -54
- package/src/cli/mcp.ts +111 -3
- package/src/cli/{config-commands.ts → memory.ts} +134 -245
- package/src/cli/notifications.ts +407 -0
- package/src/cli/program.ts +65 -0
- package/src/cli/reference.ts +48 -0
- package/src/cli/sequence.ts +154 -0
- package/src/cli/sessions.ts +262 -0
- package/src/cli/trust.ts +175 -0
- package/src/cli/twitter.ts +323 -106
- package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
- package/src/config/bundled-skills/amazon/SKILL.md +2 -2
- package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
- package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
- package/src/config/bundled-skills/contacts/SKILL.md +178 -10
- package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
- package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/core-schema.ts +7 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/loader.ts +26 -0
- package/src/config/schema.ts +4 -0
- package/src/config/skill-state.ts +0 -13
- package/src/config/system-prompt.ts +27 -0
- package/src/contacts/contact-store.ts +25 -0
- package/src/daemon/computer-use-session.ts +1 -1
- package/src/daemon/handlers/apps.ts +1 -0
- package/src/daemon/handlers/config-channels.ts +3 -3
- package/src/daemon/handlers/config-dispatch.ts +29 -0
- package/src/daemon/handlers/config-inbox.ts +4 -3
- package/src/daemon/handlers/config.ts +3 -43
- package/src/daemon/handlers/contacts.ts +34 -0
- package/src/daemon/handlers/index.ts +17 -3
- package/src/daemon/handlers/session-user-message.ts +7 -0
- package/src/daemon/handlers/sessions.ts +21 -2
- package/src/daemon/handlers/shared.ts +17 -0
- package/src/daemon/ipc-contract/apps.ts +2 -0
- package/src/daemon/ipc-contract/computer-use.ts +9 -0
- package/src/daemon/ipc-contract/contacts.ts +3 -3
- package/src/daemon/ipc-contract/inbox.ts +2 -0
- package/src/daemon/ipc-contract/messages.ts +4 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +0 -5
- package/src/daemon/ride-shotgun-handler.ts +139 -25
- package/src/daemon/session-agent-loop-handlers.ts +100 -0
- package/src/daemon/session-agent-loop.ts +72 -0
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/daemon/session.ts +23 -1
- package/src/daemon/tool-side-effects.ts +39 -1
- package/src/email/service.ts +59 -2
- package/src/index.ts +2 -60
- package/src/mcp/mcp-oauth-provider.ts +90 -8
- package/src/media/app-icon-generator.ts +86 -0
- package/src/memory/db-init.ts +11 -0
- package/src/memory/llm-usage-store.ts +186 -0
- package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
- package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/shared-app-links-store.ts +1 -1
- package/src/messaging/registry.ts +27 -0
- package/src/notifications/README.md +79 -70
- package/src/notifications/broadcaster.ts +2 -1
- package/src/notifications/conversation-pairing.ts +147 -13
- package/src/notifications/copy-composer.ts +7 -3
- package/src/notifications/destination-resolver.ts +14 -1
- package/src/notifications/emit-signal.ts +3 -2
- package/src/notifications/signal.ts +105 -1
- package/src/notifications/types.ts +16 -0
- package/src/permissions/checker.ts +29 -3
- package/src/permissions/prompter.ts +11 -3
- package/src/runtime/access-request-helper.ts +2 -1
- package/src/runtime/auth/route-policy.ts +7 -1
- package/src/runtime/channel-invite-transport.ts +40 -63
- package/src/runtime/channel-invite-transports/email.ts +13 -39
- package/src/runtime/channel-invite-transports/slack.ts +5 -34
- package/src/runtime/channel-invite-transports/sms.ts +8 -29
- package/src/runtime/channel-invite-transports/telegram.ts +69 -28
- package/src/runtime/channel-invite-transports/voice.ts +0 -7
- package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
- package/src/runtime/channel-readiness-service.ts +202 -45
- package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
- package/src/runtime/guardian-outbound-actions.ts +8 -5
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/invite-instruction-generator.ts +178 -0
- package/src/runtime/invite-service.ts +22 -25
- package/src/runtime/migrations/migration-transport.ts +13 -0
- package/src/runtime/routes/app-routes.ts +1 -1
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
- package/src/runtime/routes/channel-readiness-routes.ts +30 -11
- package/src/runtime/routes/contact-routes.ts +54 -26
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
- package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
- package/src/runtime/routes/integration-routes.ts +1 -1
- package/src/runtime/routes/invite-routes.ts +1 -1
- package/src/runtime/routes/secret-routes.ts +31 -7
- package/src/runtime/routes/twilio-routes.ts +32 -1
- package/src/runtime/routes/usage-routes.ts +114 -0
- package/src/runtime/tool-grant-request-helper.ts +2 -1
- package/src/security/encrypted-store.ts +9 -5
- package/src/security/keychain-broker-client.ts +393 -0
- package/src/security/secure-keys.ts +106 -321
- package/src/tools/apps/executors.ts +73 -0
- package/src/tools/browser/auto-navigate.ts +15 -6
- package/src/tools/browser/chrome-cdp.ts +211 -0
- package/src/tools/browser/network-recorder.test.ts +83 -0
- package/src/tools/browser/network-recorder.ts +8 -7
- package/src/tools/browser/x-auto-navigate.ts +12 -6
- package/src/tools/credentials/policy-types.ts +24 -0
- package/src/tools/credentials/vault.ts +22 -27
- package/src/tools/network/script-proxy/session-manager.ts +47 -3
- package/src/tools/permission-checker.ts +1 -0
- package/src/tools/types.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +1 -2
- package/src/tools/watch/watch-state.ts +2 -0
- package/src/__tests__/key-migration.test.ts +0 -240
- package/src/__tests__/keychain.test.ts +0 -286
- package/src/cli/core-commands.ts +0 -899
- package/src/security/keychain-to-encrypted-migration.ts +0 -66
- package/src/security/keychain.ts +0 -490
|
@@ -47,7 +47,7 @@ export function handleListInvites(url: URL): Response {
|
|
|
47
47
|
export async function handleCreateInvite(req: Request): Promise<Response> {
|
|
48
48
|
const body = (await req.json()) as Record<string, unknown>;
|
|
49
49
|
|
|
50
|
-
const result = createIngressInvite({
|
|
50
|
+
const result = await createIngressInvite({
|
|
51
51
|
sourceChannel: body.sourceChannel as string | undefined,
|
|
52
52
|
note: body.note as string | undefined,
|
|
53
53
|
maxUses: body.maxUses as number | undefined,
|
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
invalidateConfigCache,
|
|
5
5
|
} from "../../config/loader.js";
|
|
6
6
|
import { initializeProviders } from "../../providers/registry.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
deleteSecureKeyAsync,
|
|
9
|
+
getSecureKeyAsync,
|
|
10
|
+
setSecureKeyAsync,
|
|
11
|
+
} from "../../security/secure-keys.js";
|
|
8
12
|
import {
|
|
9
13
|
assertMetadataWritable,
|
|
10
14
|
deleteCredentialMetadata,
|
|
@@ -48,7 +52,7 @@ export async function handleAddSecret(req: Request): Promise<Response> {
|
|
|
48
52
|
400,
|
|
49
53
|
);
|
|
50
54
|
}
|
|
51
|
-
const stored =
|
|
55
|
+
const stored = await setSecureKeyAsync(name, value);
|
|
52
56
|
if (!stored) {
|
|
53
57
|
return httpError(
|
|
54
58
|
"INTERNAL_ERROR",
|
|
@@ -75,7 +79,7 @@ export async function handleAddSecret(req: Request): Promise<Response> {
|
|
|
75
79
|
const service = name.slice(0, colonIdx);
|
|
76
80
|
const field = name.slice(colonIdx + 1);
|
|
77
81
|
const key = `credential:${service}:${field}`;
|
|
78
|
-
const stored =
|
|
82
|
+
const stored = await setSecureKeyAsync(key, value);
|
|
79
83
|
if (!stored) {
|
|
80
84
|
return httpError(
|
|
81
85
|
"INTERNAL_ERROR",
|
|
@@ -128,10 +132,20 @@ export async function handleDeleteSecret(req: Request): Promise<Response> {
|
|
|
128
132
|
400,
|
|
129
133
|
);
|
|
130
134
|
}
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
// Check existence first — the broker always returns "deleted" even
|
|
136
|
+
// for keys that don't exist, so we need a pre-check for 404 semantics.
|
|
137
|
+
const existing = await getSecureKeyAsync(name);
|
|
138
|
+
if (existing === undefined) {
|
|
133
139
|
return httpError("NOT_FOUND", `API key not found: ${name}`, 404);
|
|
134
140
|
}
|
|
141
|
+
const deleteResult = await deleteSecureKeyAsync(name);
|
|
142
|
+
if (deleteResult === "error") {
|
|
143
|
+
return httpError(
|
|
144
|
+
"INTERNAL_ERROR",
|
|
145
|
+
`Failed to delete API key from secure storage: ${name}`,
|
|
146
|
+
500,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
135
149
|
invalidateConfigCache();
|
|
136
150
|
initializeProviders(getConfig());
|
|
137
151
|
log.info({ provider: name }, "API key deleted via HTTP");
|
|
@@ -151,10 +165,20 @@ export async function handleDeleteSecret(req: Request): Promise<Response> {
|
|
|
151
165
|
const field = name.slice(colonIdx + 1);
|
|
152
166
|
assertMetadataWritable();
|
|
153
167
|
const key = `credential:${service}:${field}`;
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
// Check existence first — the broker always returns "deleted" even
|
|
169
|
+
// for keys that don't exist, so we need a pre-check for 404 semantics.
|
|
170
|
+
const existing = await getSecureKeyAsync(key);
|
|
171
|
+
if (existing === undefined) {
|
|
156
172
|
return httpError("NOT_FOUND", `Credential not found: ${name}`, 404);
|
|
157
173
|
}
|
|
174
|
+
const deleteResult = await deleteSecureKeyAsync(key);
|
|
175
|
+
if (deleteResult === "error") {
|
|
176
|
+
return httpError(
|
|
177
|
+
"INTERNAL_ERROR",
|
|
178
|
+
`Failed to delete credential from secure storage: ${name}`,
|
|
179
|
+
500,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
158
182
|
deleteCredentialMetadata(service, field);
|
|
159
183
|
log.info({ service, field }, "Credential deleted via HTTP");
|
|
160
184
|
return Response.json({ success: true, type, name });
|
|
@@ -148,6 +148,9 @@ function pruneAssistantPhoneNumbers(
|
|
|
148
148
|
*/
|
|
149
149
|
export function handleGetTwilioConfig(): Response {
|
|
150
150
|
const hasCredentials = hasTwilioCredentials();
|
|
151
|
+
const accountSid = hasCredentials
|
|
152
|
+
? getSecureKey("credential:twilio:account_sid")
|
|
153
|
+
: undefined;
|
|
151
154
|
const raw = loadRawConfig();
|
|
152
155
|
const sms = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
153
156
|
const phoneNumber = (sms.phoneNumber as string) ?? "";
|
|
@@ -155,6 +158,7 @@ export function handleGetTwilioConfig(): Response {
|
|
|
155
158
|
return Response.json({
|
|
156
159
|
success: true,
|
|
157
160
|
hasCredentials,
|
|
161
|
+
accountSid: accountSid || undefined,
|
|
158
162
|
phoneNumber: phoneNumber || undefined,
|
|
159
163
|
});
|
|
160
164
|
}
|
|
@@ -235,7 +239,34 @@ export async function handleSetTwilioCredentials(
|
|
|
235
239
|
});
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
upsertCredentialMetadata("twilio", "account_sid", {
|
|
242
|
+
upsertCredentialMetadata("twilio", "account_sid", {
|
|
243
|
+
injectionTemplates: [
|
|
244
|
+
{
|
|
245
|
+
hostPattern: "api.twilio.com",
|
|
246
|
+
injectionType: "header" as const,
|
|
247
|
+
headerName: "Authorization",
|
|
248
|
+
valuePrefix: "Basic ",
|
|
249
|
+
valueTransform: "base64" as const,
|
|
250
|
+
composeWith: {
|
|
251
|
+
service: "twilio",
|
|
252
|
+
field: "auth_token",
|
|
253
|
+
separator: ":",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
hostPattern: "messaging.twilio.com",
|
|
258
|
+
injectionType: "header" as const,
|
|
259
|
+
headerName: "Authorization",
|
|
260
|
+
valuePrefix: "Basic ",
|
|
261
|
+
valueTransform: "base64" as const,
|
|
262
|
+
composeWith: {
|
|
263
|
+
service: "twilio",
|
|
264
|
+
field: "auth_token",
|
|
265
|
+
separator: ":",
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
});
|
|
239
270
|
upsertCredentialMetadata("twilio", "auth_token", {});
|
|
240
271
|
|
|
241
272
|
return Response.json({ success: true, hasCredentials: true });
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handlers for usage and cost summary endpoints.
|
|
3
|
+
*
|
|
4
|
+
* GET /v1/usage/totals?from=&to= — aggregate totals for a time range
|
|
5
|
+
* GET /v1/usage/daily?from=&to= — per-day buckets for a time range
|
|
6
|
+
* GET /v1/usage/breakdown?from=&to=&groupBy= — grouped breakdown (actor, provider, model)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
getUsageDayBuckets,
|
|
11
|
+
getUsageGroupBreakdown,
|
|
12
|
+
getUsageTotals,
|
|
13
|
+
} from "../../memory/llm-usage-store.js";
|
|
14
|
+
import { httpError } from "../http-errors.js";
|
|
15
|
+
import type { RouteDefinition } from "../http-router.js";
|
|
16
|
+
|
|
17
|
+
const VALID_GROUP_BY = new Set(["actor", "provider", "model"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse and validate the `from` and `to` epoch-millis query parameters.
|
|
21
|
+
* Returns the parsed range or an error Response.
|
|
22
|
+
*/
|
|
23
|
+
function parseTimeRange(url: URL): { from: number; to: number } | Response {
|
|
24
|
+
const fromRaw = url.searchParams.get("from");
|
|
25
|
+
const toRaw = url.searchParams.get("to");
|
|
26
|
+
|
|
27
|
+
if (!fromRaw || !toRaw) {
|
|
28
|
+
return httpError(
|
|
29
|
+
"BAD_REQUEST",
|
|
30
|
+
'Missing required query parameters: "from" and "to" (epoch milliseconds)',
|
|
31
|
+
400,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const from = Number(fromRaw);
|
|
36
|
+
const to = Number(toRaw);
|
|
37
|
+
|
|
38
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
39
|
+
return httpError(
|
|
40
|
+
"BAD_REQUEST",
|
|
41
|
+
'"from" and "to" must be valid numbers (epoch milliseconds)',
|
|
42
|
+
400,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (from > to) {
|
|
47
|
+
return httpError(
|
|
48
|
+
"BAD_REQUEST",
|
|
49
|
+
'"from" must be less than or equal to "to"',
|
|
50
|
+
400,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { from, to };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Route definitions
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
export function usageRouteDefinitions(): RouteDefinition[] {
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
endpoint: "usage/totals",
|
|
65
|
+
method: "GET",
|
|
66
|
+
handler: ({ url }) => {
|
|
67
|
+
const range = parseTimeRange(url);
|
|
68
|
+
if (range instanceof Response) return range;
|
|
69
|
+
const totals = getUsageTotals(range);
|
|
70
|
+
return Response.json(totals);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
endpoint: "usage/daily",
|
|
75
|
+
method: "GET",
|
|
76
|
+
handler: ({ url }) => {
|
|
77
|
+
const range = parseTimeRange(url);
|
|
78
|
+
if (range instanceof Response) return range;
|
|
79
|
+
const buckets = getUsageDayBuckets(range);
|
|
80
|
+
return Response.json({ buckets });
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
endpoint: "usage/breakdown",
|
|
85
|
+
method: "GET",
|
|
86
|
+
handler: ({ url }) => {
|
|
87
|
+
const range = parseTimeRange(url);
|
|
88
|
+
if (range instanceof Response) return range;
|
|
89
|
+
|
|
90
|
+
const groupBy = url.searchParams.get("groupBy");
|
|
91
|
+
if (!groupBy) {
|
|
92
|
+
return httpError(
|
|
93
|
+
"BAD_REQUEST",
|
|
94
|
+
'Missing required query parameter: "groupBy" (one of: actor, provider, model)',
|
|
95
|
+
400,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!VALID_GROUP_BY.has(groupBy)) {
|
|
99
|
+
return httpError(
|
|
100
|
+
"BAD_REQUEST",
|
|
101
|
+
`Invalid "groupBy" value: "${groupBy}". Must be one of: actor, provider, model`,
|
|
102
|
+
400,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const breakdown = getUsageGroupBreakdown(
|
|
107
|
+
range,
|
|
108
|
+
groupBy as "actor" | "provider" | "model",
|
|
109
|
+
);
|
|
110
|
+
return Response.json({ breakdown });
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
listCanonicalGuardianRequests,
|
|
19
19
|
} from "../memory/canonical-guardian-store.js";
|
|
20
20
|
import { emitNotificationSignal } from "../notifications/emit-signal.js";
|
|
21
|
+
import type { NotificationSourceChannel } from "../notifications/signal.js";
|
|
21
22
|
import { getLogger } from "../util/logger.js";
|
|
22
23
|
import { getGuardianBinding } from "./channel-guardian-service.js";
|
|
23
24
|
import { GUARDIAN_APPROVAL_TTL_MS } from "./routes/channel-route-shared.js";
|
|
@@ -144,7 +145,7 @@ export function createOrReuseToolGrantRequest(
|
|
|
144
145
|
// pipeline is preserved.
|
|
145
146
|
const signalPromise = emitNotificationSignal({
|
|
146
147
|
sourceEventName: "guardian.question",
|
|
147
|
-
sourceChannel,
|
|
148
|
+
sourceChannel: sourceChannel as NotificationSourceChannel,
|
|
148
149
|
sourceSessionId: conversationId,
|
|
149
150
|
attentionHints: {
|
|
150
151
|
requiresAction: true,
|
|
@@ -251,22 +251,26 @@ export function setKey(account: string, value: string): boolean {
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/** Result of a delete operation — distinguishes success, not-found, and error. */
|
|
255
|
+
export type DeleteKeyResult = "deleted" | "not-found" | "error";
|
|
256
|
+
|
|
254
257
|
/**
|
|
255
258
|
* Delete a secret from the encrypted store.
|
|
256
|
-
* Returns
|
|
259
|
+
* Returns `"deleted"` on success, `"not-found"` if the key doesn't exist,
|
|
260
|
+
* or `"error"` on failure.
|
|
257
261
|
*/
|
|
258
|
-
export function deleteKey(account: string):
|
|
262
|
+
export function deleteKey(account: string): DeleteKeyResult {
|
|
259
263
|
try {
|
|
260
264
|
const store = readStore();
|
|
261
265
|
if (!store || !Object.prototype.hasOwnProperty.call(store.entries, account))
|
|
262
|
-
return
|
|
266
|
+
return "not-found";
|
|
263
267
|
|
|
264
268
|
delete store.entries[account];
|
|
265
269
|
writeStore(store);
|
|
266
|
-
return
|
|
270
|
+
return "deleted";
|
|
267
271
|
} catch (err) {
|
|
268
272
|
log.debug({ err, account }, "Failed to delete from encrypted store");
|
|
269
|
-
return
|
|
273
|
+
return "error";
|
|
270
274
|
}
|
|
271
275
|
}
|
|
272
276
|
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript client for the keychain broker Unix domain socket protocol.
|
|
3
|
+
*
|
|
4
|
+
* The keychain broker runs inside the macOS app and exposes SecItem*
|
|
5
|
+
* operations over a newline-delimited JSON protocol on a UDS. This client
|
|
6
|
+
* provides a graceful-fallback interface: every public method returns a
|
|
7
|
+
* safe default on failure and never throws.
|
|
8
|
+
*
|
|
9
|
+
* Socket path: read from VELLUM_KEYCHAIN_BROKER_SOCKET env var.
|
|
10
|
+
* Auth token: read from ~/.vellum/protected/keychain-broker.token on first
|
|
11
|
+
* connection, cached for process lifetime.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import type { Socket } from "node:net";
|
|
17
|
+
import { createConnection } from "node:net";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { pathExists } from "../util/fs.js";
|
|
21
|
+
import { getLogger } from "../util/logger.js";
|
|
22
|
+
import { getRootDir } from "../util/platform.js";
|
|
23
|
+
|
|
24
|
+
const log = getLogger("keychain-broker-client");
|
|
25
|
+
|
|
26
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Types
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** Result of a `get()` call. `null` means broker error (caller should fall
|
|
33
|
+
* back); `{ found: false }` means the key doesn't exist in the keychain. */
|
|
34
|
+
export type BrokerGetResult = { found: boolean; value?: string } | null;
|
|
35
|
+
|
|
36
|
+
export interface KeychainBrokerClient {
|
|
37
|
+
isAvailable(): boolean;
|
|
38
|
+
ping(): Promise<{ pong: boolean } | null>;
|
|
39
|
+
get(account: string): Promise<BrokerGetResult>;
|
|
40
|
+
set(account: string, value: string): Promise<boolean>;
|
|
41
|
+
del(account: string): Promise<boolean>;
|
|
42
|
+
list(): Promise<string[]>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface BrokerRequest {
|
|
46
|
+
v: number;
|
|
47
|
+
id: string;
|
|
48
|
+
method: string;
|
|
49
|
+
token: string;
|
|
50
|
+
params?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BrokerResponse {
|
|
54
|
+
id: string;
|
|
55
|
+
ok: boolean;
|
|
56
|
+
result?: Record<string, unknown>;
|
|
57
|
+
error?: { code: string; message: string };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface PendingRequest {
|
|
61
|
+
resolve: (response: BrokerResponse) => void;
|
|
62
|
+
timer: ReturnType<typeof setTimeout>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Internal state
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function getTokenPath(): string {
|
|
70
|
+
return join(getRootDir(), "protected", "keychain-broker.token");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getSocketPath(): string | undefined {
|
|
74
|
+
return process.env.VELLUM_KEYCHAIN_BROKER_SOCKET;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Client implementation
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export function createBrokerClient(): KeychainBrokerClient {
|
|
82
|
+
let socket: Socket | null = null;
|
|
83
|
+
/** Promise that resolves when the in-flight connect() completes. */
|
|
84
|
+
let connectPromise: Promise<Socket> | null = null;
|
|
85
|
+
let permanentlyUnavailable = false;
|
|
86
|
+
/** Cached token string, or undefined if not yet successfully read. */
|
|
87
|
+
let cachedToken: string | undefined;
|
|
88
|
+
let hasTriedReconnect = false;
|
|
89
|
+
|
|
90
|
+
/** Buffer for incoming data — responses are newline-delimited JSON. */
|
|
91
|
+
let inBuffer = "";
|
|
92
|
+
|
|
93
|
+
const pending = new Map<string, PendingRequest>();
|
|
94
|
+
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
// Token management
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function readToken(): string | null {
|
|
100
|
+
try {
|
|
101
|
+
const tokenPath = getTokenPath();
|
|
102
|
+
if (!pathExists(tokenPath)) return null;
|
|
103
|
+
return readFileSync(tokenPath, "utf-8").trim();
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getToken(): string | null {
|
|
110
|
+
if (cachedToken !== undefined) return cachedToken;
|
|
111
|
+
const token = readToken();
|
|
112
|
+
// Only cache non-null results so we re-attempt on next call if the
|
|
113
|
+
// token file hasn't appeared yet (startup race).
|
|
114
|
+
if (token) cachedToken = token;
|
|
115
|
+
return token;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Re-read the token from disk (handles app restart with new token). */
|
|
119
|
+
function refreshToken(): string | null {
|
|
120
|
+
const token = readToken();
|
|
121
|
+
// Update the cache: set to the new value if found, clear if not so
|
|
122
|
+
// subsequent getToken() calls will re-read from disk.
|
|
123
|
+
cachedToken = token ?? undefined;
|
|
124
|
+
return token;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// -------------------------------------------------------------------------
|
|
128
|
+
// Socket lifecycle
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function handleData(chunk: Buffer | string): void {
|
|
132
|
+
inBuffer += chunk.toString();
|
|
133
|
+
let newlineIdx: number;
|
|
134
|
+
while ((newlineIdx = inBuffer.indexOf("\n")) !== -1) {
|
|
135
|
+
const line = inBuffer.slice(0, newlineIdx).trim();
|
|
136
|
+
inBuffer = inBuffer.slice(newlineIdx + 1);
|
|
137
|
+
if (!line) continue;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const response = JSON.parse(line) as BrokerResponse;
|
|
141
|
+
const entry = pending.get(response.id);
|
|
142
|
+
if (entry) {
|
|
143
|
+
clearTimeout(entry.timer);
|
|
144
|
+
pending.delete(response.id);
|
|
145
|
+
entry.resolve(response);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
log.warn("Received malformed JSON from keychain broker");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cleanupSocket(): void {
|
|
154
|
+
if (socket) {
|
|
155
|
+
socket.removeAllListeners();
|
|
156
|
+
socket.destroy();
|
|
157
|
+
socket = null;
|
|
158
|
+
}
|
|
159
|
+
inBuffer = "";
|
|
160
|
+
// Reject all pending requests
|
|
161
|
+
for (const [id, entry] of pending) {
|
|
162
|
+
clearTimeout(entry.timer);
|
|
163
|
+
entry.resolve({
|
|
164
|
+
id,
|
|
165
|
+
ok: false,
|
|
166
|
+
error: { code: "DISCONNECTED", message: "disconnected" },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
pending.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function connect(): Promise<Socket> {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const socketPath = getSocketPath();
|
|
175
|
+
if (!socketPath) {
|
|
176
|
+
reject(new Error("No socket path"));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sock = createConnection({ path: socketPath });
|
|
181
|
+
|
|
182
|
+
sock.on("connect", () => {
|
|
183
|
+
socket = sock;
|
|
184
|
+
hasTriedReconnect = false;
|
|
185
|
+
resolve(sock);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
sock.on("error", (err) => {
|
|
189
|
+
log.warn({ err }, "Keychain broker socket error");
|
|
190
|
+
cleanupSocket();
|
|
191
|
+
reject(err);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
sock.on("close", () => {
|
|
195
|
+
cleanupSocket();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
sock.on("data", handleData);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function ensureConnected(): Promise<Socket | null> {
|
|
203
|
+
if (permanentlyUnavailable) return null;
|
|
204
|
+
if (socket && !socket.destroyed) return socket;
|
|
205
|
+
|
|
206
|
+
// If a connect() is already in flight, wait for it instead of returning
|
|
207
|
+
// null — this prevents concurrent callers from silently failing.
|
|
208
|
+
if (connectPromise) {
|
|
209
|
+
try {
|
|
210
|
+
return await connectPromise;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
connectPromise = connect();
|
|
217
|
+
try {
|
|
218
|
+
const sock = await connectPromise;
|
|
219
|
+
return sock;
|
|
220
|
+
} catch {
|
|
221
|
+
// First connection failed — try once more
|
|
222
|
+
if (!hasTriedReconnect) {
|
|
223
|
+
hasTriedReconnect = true;
|
|
224
|
+
connectPromise = connect();
|
|
225
|
+
try {
|
|
226
|
+
return await connectPromise;
|
|
227
|
+
} catch {
|
|
228
|
+
// Reconnect also failed — mark unavailable
|
|
229
|
+
log.warn(
|
|
230
|
+
"Keychain broker reconnect failed, marking unavailable for this process",
|
|
231
|
+
);
|
|
232
|
+
permanentlyUnavailable = true;
|
|
233
|
+
return null;
|
|
234
|
+
} finally {
|
|
235
|
+
connectPromise = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
permanentlyUnavailable = true;
|
|
239
|
+
return null;
|
|
240
|
+
} finally {
|
|
241
|
+
connectPromise = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// -------------------------------------------------------------------------
|
|
246
|
+
// Request / response
|
|
247
|
+
// -------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
function sendRequest(request: BrokerRequest): Promise<BrokerResponse> {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
if (!socket || socket.destroyed) {
|
|
252
|
+
resolve({
|
|
253
|
+
id: request.id,
|
|
254
|
+
ok: false,
|
|
255
|
+
error: { code: "NOT_CONNECTED", message: "not connected" },
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const timer = setTimeout(() => {
|
|
261
|
+
pending.delete(request.id);
|
|
262
|
+
resolve({
|
|
263
|
+
id: request.id,
|
|
264
|
+
ok: false,
|
|
265
|
+
error: { code: "TIMEOUT", message: "timeout" },
|
|
266
|
+
});
|
|
267
|
+
}, REQUEST_TIMEOUT_MS);
|
|
268
|
+
|
|
269
|
+
pending.set(request.id, { resolve, timer });
|
|
270
|
+
|
|
271
|
+
const data = JSON.stringify(request) + "\n";
|
|
272
|
+
socket.write(data, (err) => {
|
|
273
|
+
if (err) {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
pending.delete(request.id);
|
|
276
|
+
resolve({
|
|
277
|
+
id: request.id,
|
|
278
|
+
ok: false,
|
|
279
|
+
error: { code: "WRITE_ERROR", message: "write error" },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function doRequest(
|
|
287
|
+
method: string,
|
|
288
|
+
params: Record<string, unknown> = {},
|
|
289
|
+
): Promise<BrokerResponse | null> {
|
|
290
|
+
const sock = await ensureConnected();
|
|
291
|
+
if (!sock) return null;
|
|
292
|
+
|
|
293
|
+
const token = getToken();
|
|
294
|
+
if (!token) return null;
|
|
295
|
+
|
|
296
|
+
const id = randomUUID();
|
|
297
|
+
const request: BrokerRequest = {
|
|
298
|
+
v: 1,
|
|
299
|
+
id,
|
|
300
|
+
method,
|
|
301
|
+
token,
|
|
302
|
+
...(Object.keys(params).length > 0 ? { params } : {}),
|
|
303
|
+
};
|
|
304
|
+
const response = await sendRequest(request);
|
|
305
|
+
|
|
306
|
+
// On UNAUTHORIZED, re-read the token once and retry. This handles
|
|
307
|
+
// the case where the app restarted with a new token while the daemon
|
|
308
|
+
// is still running with the old cached one.
|
|
309
|
+
if (response.error?.code === "UNAUTHORIZED") {
|
|
310
|
+
const newToken = refreshToken();
|
|
311
|
+
if (!newToken || newToken === request.token) return response;
|
|
312
|
+
|
|
313
|
+
const retryRequest: BrokerRequest = {
|
|
314
|
+
...request,
|
|
315
|
+
id: randomUUID(),
|
|
316
|
+
token: newToken,
|
|
317
|
+
};
|
|
318
|
+
return await sendRequest(retryRequest);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return response;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// -------------------------------------------------------------------------
|
|
325
|
+
// Public API
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
isAvailable(): boolean {
|
|
330
|
+
if (permanentlyUnavailable) return false;
|
|
331
|
+
const socketPath = getSocketPath();
|
|
332
|
+
if (!socketPath) return false;
|
|
333
|
+
return pathExists(getTokenPath());
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
async ping(): Promise<{ pong: boolean } | null> {
|
|
337
|
+
try {
|
|
338
|
+
const response = await doRequest("broker.ping");
|
|
339
|
+
if (!response || !response.ok) return null;
|
|
340
|
+
return {
|
|
341
|
+
pong: !!(response.result as Record<string, unknown> | undefined)
|
|
342
|
+
?.pong,
|
|
343
|
+
};
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
async get(account: string): Promise<BrokerGetResult> {
|
|
350
|
+
try {
|
|
351
|
+
const response = await doRequest("key.get", { account });
|
|
352
|
+
if (!response) return null;
|
|
353
|
+
if (!response.ok) return null;
|
|
354
|
+
const result = response.result as
|
|
355
|
+
| { found?: boolean; value?: string }
|
|
356
|
+
| undefined;
|
|
357
|
+
if (!result) return null;
|
|
358
|
+
return { found: !!result.found, value: result.value };
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
async set(account: string, value: string): Promise<boolean> {
|
|
365
|
+
try {
|
|
366
|
+
const response = await doRequest("key.set", { account, value });
|
|
367
|
+
return response?.ok === true;
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
async del(account: string): Promise<boolean> {
|
|
374
|
+
try {
|
|
375
|
+
const response = await doRequest("key.delete", { account });
|
|
376
|
+
return response?.ok === true;
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
async list(): Promise<string[]> {
|
|
383
|
+
try {
|
|
384
|
+
const response = await doRequest("key.list");
|
|
385
|
+
if (!response || !response.ok) return [];
|
|
386
|
+
const result = response.result as { accounts?: string[] } | undefined;
|
|
387
|
+
return result?.accounts ?? [];
|
|
388
|
+
} catch {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|