@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
|
@@ -17,6 +17,8 @@ const log = getLogger('runtime-http');
|
|
|
17
17
|
export interface PairingHandlerContext {
|
|
18
18
|
pairingStore: PairingStore;
|
|
19
19
|
bearerToken: string | undefined;
|
|
20
|
+
/** Feature-flag client token to include in pairing approval responses so iOS can PATCH flags. */
|
|
21
|
+
featureFlagToken: string | undefined;
|
|
20
22
|
pairingBroadcast?: (msg: ServerMessage) => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -90,6 +92,7 @@ export async function handlePairingRequest(req: Request, ctx: PairingHandlerCont
|
|
|
90
92
|
bearerToken: ctx.bearerToken,
|
|
91
93
|
gatewayUrl: entry.gatewayUrl,
|
|
92
94
|
localLanUrl: entry.localLanUrl,
|
|
95
|
+
...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
|
|
93
96
|
});
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -138,6 +141,7 @@ export function handlePairingStatus(url: URL, ctx: PairingHandlerContext): Respo
|
|
|
138
141
|
bearerToken: entry.bearerToken,
|
|
139
142
|
gatewayUrl: entry.gatewayUrl,
|
|
140
143
|
localLanUrl: entry.localLanUrl,
|
|
144
|
+
...(ctx.featureFlagToken ? { featureFlagToken: ctx.featureFlagToken } : {}),
|
|
141
145
|
});
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
pbkdf2Sync,
|
|
18
18
|
randomBytes,
|
|
19
19
|
} from 'node:crypto';
|
|
20
|
-
import { chmodSync,readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { chmodSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { hostname, userInfo } from 'node:os';
|
|
22
|
-
import { dirname,join } from 'node:path';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { ensureDir,pathExists } from '../util/fs.js';
|
|
25
25
|
import { getLogger } from '../util/logger.js';
|
|
@@ -94,24 +94,40 @@ function deriveKey(salt: Buffer): Buffer {
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Read result: distinguishes "file missing" from "file corrupt/unreadable".
|
|
97
|
-
* - `null`: file does not exist (
|
|
97
|
+
* - `null`: file does not exist or was corrupt (backed up and removed)
|
|
98
98
|
* - `StoreFile`: successfully parsed
|
|
99
|
-
* - throws:
|
|
99
|
+
* - throws: transient I/O error from readFileSync (EACCES, EMFILE, EIO, etc.)
|
|
100
100
|
*/
|
|
101
101
|
function readStore(): StoreFile | null {
|
|
102
102
|
const path = getStorePath();
|
|
103
103
|
if (!pathExists(path)) return null;
|
|
104
104
|
|
|
105
|
+
// Read outside the parse try/catch so transient filesystem errors (EACCES,
|
|
106
|
+
// EMFILE, EIO) propagate to callers instead of triggering corruption recovery.
|
|
105
107
|
const raw = readFileSync(path, 'utf-8');
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
if (parsed.version !== 1 || typeof parsed.salt !== 'string' || typeof parsed.entries !== 'object') {
|
|
112
|
+
throw new Error('Encrypted store has invalid format');
|
|
113
|
+
}
|
|
114
|
+
// Use null-prototype object for entries to prevent prototype pollution
|
|
115
|
+
const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
|
|
116
|
+
Object.assign(safeEntries, parsed.entries);
|
|
117
|
+
parsed.entries = safeEntries;
|
|
118
|
+
return parsed as StoreFile;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Corrupted or invalid store file — back it up and start fresh so the
|
|
121
|
+
// daemon doesn't crash on every credential access.
|
|
122
|
+
const backupPath = `${path}.corrupt.${Date.now()}`;
|
|
123
|
+
log.error({ err, backupPath }, 'Encrypted store is corrupt — backing up and resetting');
|
|
124
|
+
try {
|
|
125
|
+
renameSync(path, backupPath);
|
|
126
|
+
} catch (renameErr) {
|
|
127
|
+
log.warn({ err: renameErr }, 'Failed to back up corrupt store file');
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
109
130
|
}
|
|
110
|
-
// Use null-prototype object for entries to prevent prototype pollution
|
|
111
|
-
const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
|
|
112
|
-
Object.assign(safeEntries, parsed.entries);
|
|
113
|
-
parsed.entries = safeEntries;
|
|
114
|
-
return parsed as StoreFile;
|
|
115
131
|
}
|
|
116
132
|
|
|
117
133
|
function writeStore(store: StoreFile): void {
|
|
@@ -123,11 +139,9 @@ function writeStore(store: StoreFile): void {
|
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
function getOrCreateStore(): StoreFile {
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
return readStore()!;
|
|
130
|
-
}
|
|
142
|
+
const existing = readStore();
|
|
143
|
+
if (existing) return existing;
|
|
144
|
+
|
|
131
145
|
const salt = randomBytes(SALT_LENGTH);
|
|
132
146
|
const entries: Record<string, EncryptedEntry> = Object.create(null);
|
|
133
147
|
const store: StoreFile = {
|
package/src/security/keychain.ts
CHANGED
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
* - macOS: uses the `security` CLI to interact with Keychain
|
|
5
5
|
* - Linux: uses `secret-tool` (libsecret) for GNOME/KDE keyrings
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Sync variants (getKey, setKey, deleteKey) match the config loader's sync API.
|
|
8
|
+
* Async variants (getKeyAsync, setKeyAsync, deleteKeyAsync) avoid blocking
|
|
9
|
+
* the event loop and should be preferred for non-startup code paths.
|
|
10
|
+
*
|
|
8
11
|
* Callers should check `isKeychainAvailable()` before use and fall back
|
|
9
12
|
* to encrypted-at-rest storage when the keychain is not accessible.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
|
-
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
13
17
|
|
|
14
18
|
import { getLogger } from '../util/logger.js';
|
|
15
19
|
import { isLinux,isMacOS } from '../util/platform.js';
|
|
16
20
|
|
|
17
21
|
const log = getLogger('keychain');
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
18
23
|
|
|
19
24
|
const SERVICE_NAME = 'vellum-assistant';
|
|
20
25
|
|
|
@@ -67,6 +72,29 @@ export function isKeychainAvailable(): boolean {
|
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
/** Async version of `isKeychainAvailable` — probes without blocking the event loop. */
|
|
76
|
+
export async function isKeychainAvailableAsync(): Promise<boolean> {
|
|
77
|
+
try {
|
|
78
|
+
if (deps.isMacOS()) {
|
|
79
|
+
await execFileAsync('security', ['list-keychains'], {
|
|
80
|
+
timeout: 5000,
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (deps.isLinux()) {
|
|
86
|
+
await execFileAsync('which', ['secret-tool'], {
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/**
|
|
71
99
|
* Retrieve a secret from the OS keychain.
|
|
72
100
|
* Returns `null` if the key doesn't exist.
|
|
@@ -251,3 +279,149 @@ function linuxDeleteKey(account: string): boolean {
|
|
|
251
279
|
return false;
|
|
252
280
|
}
|
|
253
281
|
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Async variants — non-blocking alternatives to the sync functions above.
|
|
285
|
+
// Preferred for non-startup code paths to avoid blocking the event loop.
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Async version of `getKey` — retrieve a secret without blocking the event loop.
|
|
290
|
+
* Returns `null` if the key doesn't exist.
|
|
291
|
+
* Throws on runtime errors (keychain unavailable, locked, etc.).
|
|
292
|
+
*/
|
|
293
|
+
export async function getKeyAsync(account: string): Promise<string | null> {
|
|
294
|
+
if (deps.isMacOS()) return macosGetKeyAsync(account);
|
|
295
|
+
if (deps.isLinux()) return linuxGetKeyAsync(account);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Async version of `setKey` — store a secret without blocking the event loop.
|
|
301
|
+
* Returns true on success, false on failure.
|
|
302
|
+
*/
|
|
303
|
+
export async function setKeyAsync(account: string, value: string): Promise<boolean> {
|
|
304
|
+
try {
|
|
305
|
+
if (deps.isMacOS()) return await macosSetKeyAsync(account, value);
|
|
306
|
+
if (deps.isLinux()) return await linuxSetKeyAsync(account, value);
|
|
307
|
+
return false;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
log.warn({ err, account }, 'Failed to write to keychain');
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Async version of `deleteKey` — delete a secret without blocking the event loop.
|
|
316
|
+
* Returns true on success, false if not found or on failure.
|
|
317
|
+
*/
|
|
318
|
+
export async function deleteKeyAsync(account: string): Promise<boolean> {
|
|
319
|
+
try {
|
|
320
|
+
if (deps.isMacOS()) return await macosDeleteKeyAsync(account);
|
|
321
|
+
if (deps.isLinux()) return await linuxDeleteKeyAsync(account);
|
|
322
|
+
return false;
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.debug({ err, account }, 'Failed to delete from keychain');
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Async macOS Keychain
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
async function macosGetKeyAsync(account: string): Promise<string | null> {
|
|
334
|
+
try {
|
|
335
|
+
const { stdout } = await execFileAsync('security', [
|
|
336
|
+
'find-generic-password',
|
|
337
|
+
'-s', SERVICE_NAME,
|
|
338
|
+
'-a', account,
|
|
339
|
+
'-w',
|
|
340
|
+
], { timeout: 5000 });
|
|
341
|
+
return stdout.replace(/\n$/, '') || null;
|
|
342
|
+
} catch (err: unknown) {
|
|
343
|
+
// Exit code 44 = item not found — return null.
|
|
344
|
+
if (err && typeof err === 'object' && 'code' in err && (err as { code: number }).code === 44) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function macosSetKeyAsync(account: string, value: string): Promise<boolean> {
|
|
352
|
+
try {
|
|
353
|
+
await execFileAsync('security', [
|
|
354
|
+
'add-generic-password',
|
|
355
|
+
'-s', SERVICE_NAME,
|
|
356
|
+
'-a', account,
|
|
357
|
+
'-w', value,
|
|
358
|
+
'-U',
|
|
359
|
+
], { timeout: 5000 });
|
|
360
|
+
return true;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function macosDeleteKeyAsync(account: string): Promise<boolean> {
|
|
367
|
+
try {
|
|
368
|
+
await execFileAsync('security', [
|
|
369
|
+
'delete-generic-password',
|
|
370
|
+
'-s', SERVICE_NAME,
|
|
371
|
+
'-a', account,
|
|
372
|
+
], { timeout: 5000 });
|
|
373
|
+
return true;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Async Linux via `secret-tool`
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
async function linuxGetKeyAsync(account: string): Promise<string | null> {
|
|
384
|
+
try {
|
|
385
|
+
const { stdout } = await execFileAsync('secret-tool', [
|
|
386
|
+
'lookup',
|
|
387
|
+
'service', SERVICE_NAME,
|
|
388
|
+
'account', account,
|
|
389
|
+
], { timeout: 5000 });
|
|
390
|
+
return stdout.replace(/\n$/, '') || null;
|
|
391
|
+
} catch (err: unknown) {
|
|
392
|
+
if (err && typeof err === 'object' && 'code' in err && (err as { code: number }).code === 1) {
|
|
393
|
+
const stderr = String((err as { stderr?: unknown }).stderr ?? '').trim();
|
|
394
|
+
if (stderr.length > 0) throw err;
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function linuxSetKeyAsync(account: string, value: string): Promise<boolean> {
|
|
402
|
+
// secret-tool reads the secret from stdin
|
|
403
|
+
return new Promise<boolean>((resolve) => {
|
|
404
|
+
const child = execFile('secret-tool', [
|
|
405
|
+
'store',
|
|
406
|
+
'--label', `${SERVICE_NAME}: ${account}`,
|
|
407
|
+
'service', SERVICE_NAME,
|
|
408
|
+
'account', account,
|
|
409
|
+
], { timeout: 5000 }, (err) => {
|
|
410
|
+
resolve(!err);
|
|
411
|
+
});
|
|
412
|
+
child.stdin?.end(value);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function linuxDeleteKeyAsync(account: string): Promise<boolean> {
|
|
417
|
+
try {
|
|
418
|
+
await execFileAsync('secret-tool', [
|
|
419
|
+
'clear',
|
|
420
|
+
'service', SERVICE_NAME,
|
|
421
|
+
'account', account,
|
|
422
|
+
], { timeout: 5000 });
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -30,6 +30,19 @@ function getBackend(): Backend {
|
|
|
30
30
|
return resolvedBackend;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
async function getBackendAsync(): Promise<Backend> {
|
|
34
|
+
if (resolvedBackend !== undefined) return resolvedBackend;
|
|
35
|
+
|
|
36
|
+
if (await keychain.isKeychainAvailableAsync()) {
|
|
37
|
+
log.debug('Using OS keychain for secure key storage');
|
|
38
|
+
resolvedBackend = 'keychain';
|
|
39
|
+
} else {
|
|
40
|
+
log.debug('OS keychain unavailable, using encrypted file storage');
|
|
41
|
+
resolvedBackend = 'encrypted';
|
|
42
|
+
}
|
|
43
|
+
return resolvedBackend;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
/**
|
|
34
47
|
* Try a keychain operation; on failure, permanently downgrade to encrypted
|
|
35
48
|
* backend and retry. This handles systems where the keychain CLI exists
|
|
@@ -167,6 +180,90 @@ export function isDowngradedFromKeychain(): boolean {
|
|
|
167
180
|
return downgradedFromKeychain;
|
|
168
181
|
}
|
|
169
182
|
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Async variants — non-blocking alternatives that avoid blocking the event
|
|
185
|
+
// loop during keychain operations. Preferred for non-startup code paths.
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Async version of `getSecureKey` — retrieve a secret without blocking.
|
|
190
|
+
*/
|
|
191
|
+
export async function getSecureKeyAsync(account: string): Promise<string | undefined> {
|
|
192
|
+
const backend = await getBackendAsync();
|
|
193
|
+
if (backend === 'keychain') {
|
|
194
|
+
try {
|
|
195
|
+
return (await keychain.getKeyAsync(account)) ?? undefined;
|
|
196
|
+
} catch {
|
|
197
|
+
log.warn('Keychain read failed at runtime, falling back to encrypted file storage');
|
|
198
|
+
resolvedBackend = 'encrypted';
|
|
199
|
+
downgradedFromKeychain = true;
|
|
200
|
+
return encryptedStore.getKey(account);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (backend === 'encrypted') {
|
|
204
|
+
const value = encryptedStore.getKey(account);
|
|
205
|
+
if (value === undefined && downgradedFromKeychain) {
|
|
206
|
+
try {
|
|
207
|
+
return (await keychain.getKeyAsync(account)) ?? undefined;
|
|
208
|
+
} catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Async version of `setSecureKey` — store a secret without blocking.
|
|
219
|
+
*/
|
|
220
|
+
export async function setSecureKeyAsync(account: string, value: string): Promise<boolean> {
|
|
221
|
+
const backend = await getBackendAsync();
|
|
222
|
+
if (backend === 'encrypted') return encryptedStore.setKey(account, value);
|
|
223
|
+
if (backend !== 'keychain') return false;
|
|
224
|
+
|
|
225
|
+
const result = await keychain.setKeyAsync(account, value);
|
|
226
|
+
if (result === false) {
|
|
227
|
+
log.warn('Keychain operation failed at runtime, falling back to encrypted file storage');
|
|
228
|
+
resolvedBackend = 'encrypted';
|
|
229
|
+
downgradedFromKeychain = true;
|
|
230
|
+
return encryptedStore.setKey(account, value);
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Async version of `deleteSecureKey` — delete a secret without blocking.
|
|
237
|
+
*/
|
|
238
|
+
export async function deleteSecureKeyAsync(account: string): Promise<boolean> {
|
|
239
|
+
const backend = await getBackendAsync();
|
|
240
|
+
if (backend === 'encrypted') {
|
|
241
|
+
const result = encryptedStore.deleteKey(account);
|
|
242
|
+
if (downgradedFromKeychain) {
|
|
243
|
+
await keychain.deleteKeyAsync(account); // best-effort
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
if (backend !== 'keychain') return false;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
if ((await keychain.getKeyAsync(account)) == null) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// fall through
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const result = await keychain.deleteKeyAsync(account);
|
|
258
|
+
if (result === false) {
|
|
259
|
+
log.warn('Keychain operation failed at runtime, falling back to encrypted file storage');
|
|
260
|
+
resolvedBackend = 'encrypted';
|
|
261
|
+
downgradedFromKeychain = true;
|
|
262
|
+
return encryptedStore.deleteKey(account);
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
170
267
|
/** @internal Test-only: reset the cached backend so it's re-evaluated. */
|
|
171
268
|
export function _resetBackend(): void {
|
|
172
269
|
resolvedBackend = undefined;
|
|
@@ -27,7 +27,7 @@ export function canonicalJsonSerialize(value: unknown): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function sortKeysDeep(value: unknown): unknown {
|
|
30
|
-
if (value
|
|
30
|
+
if (value == null) return value;
|
|
31
31
|
|
|
32
32
|
if (Array.isArray(value)) {
|
|
33
33
|
return value.map(sortKeysDeep);
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import type { ToolContext, ToolExecutionResult } from '../types.js';
|
|
13
13
|
import { detectAuthChallenge, detectCaptchaChallenge, formatAuthChallenge } from './auth-detector.js';
|
|
14
14
|
import type { PageResponse,RouteHandler } from './browser-manager.js';
|
|
15
|
-
import { browserManager } from './browser-manager.js';
|
|
15
|
+
import { browserManager, SCREENCAST_HEIGHT,SCREENCAST_WIDTH } from './browser-manager.js';
|
|
16
16
|
import {
|
|
17
17
|
ensureScreencast,
|
|
18
18
|
getElementBounds,
|
|
@@ -770,7 +770,7 @@ export async function executeBrowserScroll(
|
|
|
770
770
|
if (bounds) {
|
|
771
771
|
// Convert screencast coords back to page coords for mouse.move
|
|
772
772
|
const result = await page.evaluate(`(() => ({ vw: window.innerWidth, vh: window.innerHeight }))()`) as { vw: number; vh: number };
|
|
773
|
-
const scale = Math.min(
|
|
773
|
+
const scale = Math.min(SCREENCAST_WIDTH / result.vw, SCREENCAST_HEIGHT / result.vh);
|
|
774
774
|
const pageX = (bounds.x + bounds.w / 2) / scale;
|
|
775
775
|
const pageY = (bounds.y + bounds.h / 2) / scale;
|
|
776
776
|
await page.mouse.move(pageX, pageY);
|
|
@@ -10,6 +10,11 @@ import { checkBrowserRuntime } from './runtime-check.js';
|
|
|
10
10
|
|
|
11
11
|
const log = getLogger('browser-manager');
|
|
12
12
|
|
|
13
|
+
// Screencast capture dimensions — used by coordinate math across the browser module
|
|
14
|
+
// to map between page coordinates and screencast-frame coordinates.
|
|
15
|
+
export const SCREENCAST_WIDTH = 800;
|
|
16
|
+
export const SCREENCAST_HEIGHT = 600;
|
|
17
|
+
|
|
13
18
|
function getDownloadsDir(): string {
|
|
14
19
|
const dir = join(getDataDir(), 'browser-downloads');
|
|
15
20
|
mkdirSync(dir, { recursive: true });
|
|
@@ -20,6 +25,7 @@ export type DownloadInfo = { path: string; filename: string };
|
|
|
20
25
|
|
|
21
26
|
type BrowserContext = {
|
|
22
27
|
newPage(): Promise<Page>;
|
|
28
|
+
pages?(): Page[];
|
|
23
29
|
close(): Promise<void>;
|
|
24
30
|
};
|
|
25
31
|
|
|
@@ -253,32 +259,11 @@ class BrowserManager {
|
|
|
253
259
|
}
|
|
254
260
|
}
|
|
255
261
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
const pw2 = await import('playwright');
|
|
263
|
-
const headedBrowser = await pw2.chromium.launch({
|
|
264
|
-
channel: 'chrome',
|
|
265
|
-
headless: false,
|
|
266
|
-
args: [
|
|
267
|
-
'--window-position=-32000,-32000',
|
|
268
|
-
'--window-size=1,1',
|
|
269
|
-
'--disable-blink-features=AutomationControlled',
|
|
270
|
-
],
|
|
271
|
-
});
|
|
272
|
-
const ctx = headedBrowser.contexts()[0] || await headedBrowser.newContext();
|
|
273
|
-
this.cdpBrowser = headedBrowser as unknown as typeof this.cdpBrowser;
|
|
274
|
-
this._browserLaunched = true;
|
|
275
|
-
this.setBrowserMode('cdp');
|
|
276
|
-
await this.initBrowserCdpSession();
|
|
277
|
-
log.info('Launched headed Chromium (minimized) for interactive handoff support');
|
|
278
|
-
return ctx as unknown as BrowserContext;
|
|
279
|
-
} catch (err2) {
|
|
280
|
-
log.warn({ err: err2 }, 'Headed Chromium launch failed, falling back to headless');
|
|
281
|
-
}
|
|
262
|
+
if (invokingSessionId && this.sessionSenders.get(invokingSessionId) && this._browserMode === 'headless') {
|
|
263
|
+
log.info(
|
|
264
|
+
{ sessionId: invokingSessionId },
|
|
265
|
+
'CDP unavailable/declined; staying in headless mode (no visible browser window will be auto-launched)',
|
|
266
|
+
);
|
|
282
267
|
}
|
|
283
268
|
}
|
|
284
269
|
|
|
@@ -380,7 +365,26 @@ class BrowserManager {
|
|
|
380
365
|
this.snapshotMaps.delete(sessionId);
|
|
381
366
|
await this.stopScreencast(sessionId);
|
|
382
367
|
|
|
383
|
-
|
|
368
|
+
let page: Page | undefined;
|
|
369
|
+
|
|
370
|
+
// In connectOverCDP mode, Chrome often starts with a pre-opened blank tab.
|
|
371
|
+
// Only reuse blank/new-tab pages to avoid hijacking active user tabs, which
|
|
372
|
+
// could cause user-visible disruption or data loss when the session closes.
|
|
373
|
+
if (this._browserMode === 'cdp' && !this._browserLaunched && typeof context.pages === 'function') {
|
|
374
|
+
const BLANK_TAB_URLS = new Set(['about:blank', 'chrome://newtab/', 'chrome://new-tab-page/']);
|
|
375
|
+
const claimedPages = new Set(this.pages.values());
|
|
376
|
+
const reusable = context.pages().find((p) => {
|
|
377
|
+
if (p.isClosed() || claimedPages.has(p)) return false;
|
|
378
|
+
const url = p.url();
|
|
379
|
+
return BLANK_TAB_URLS.has(url) || url === '';
|
|
380
|
+
});
|
|
381
|
+
if (reusable) {
|
|
382
|
+
page = reusable;
|
|
383
|
+
log.debug({ sessionId, url: reusable.url() }, 'Reusing blank CDP tab instead of creating a new page');
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
page ??= await context.newPage();
|
|
384
388
|
this.pages.set(sessionId, page);
|
|
385
389
|
this.rawPages.set(sessionId, page);
|
|
386
390
|
|
|
@@ -509,17 +513,27 @@ class BrowserManager {
|
|
|
509
513
|
this.cdpSessions.set(sessionId, cdp);
|
|
510
514
|
this.screencastCallbacks.set(sessionId, onFrame);
|
|
511
515
|
|
|
516
|
+
// Keep screencast intentionally low-frequency to avoid Chrome renderer /
|
|
517
|
+
// WindowServer spikes while users type in interactive auth flows.
|
|
518
|
+
const MIN_FRAME_INTERVAL_MS = 1000;
|
|
519
|
+
let lastFrameTime = 0;
|
|
520
|
+
|
|
512
521
|
cdp.on('Page.screencastFrame', (params) => {
|
|
513
|
-
|
|
522
|
+
const now = Date.now();
|
|
523
|
+
if (now - lastFrameTime >= MIN_FRAME_INTERVAL_MS) {
|
|
524
|
+
lastFrameTime = now;
|
|
525
|
+
onFrame({ data: params.data as string, metadata: params.metadata as ScreencastFrameMetadata });
|
|
526
|
+
}
|
|
527
|
+
// Always ack so CDP continues delivering frames (otherwise it stalls)
|
|
514
528
|
silentlyWithLog(cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }), 'screencast frame ack');
|
|
515
529
|
});
|
|
516
530
|
|
|
517
531
|
await cdp.send('Page.startScreencast', {
|
|
518
532
|
format: 'jpeg',
|
|
519
|
-
quality:
|
|
520
|
-
maxWidth:
|
|
521
|
-
maxHeight:
|
|
522
|
-
everyNthFrame:
|
|
533
|
+
quality: 30,
|
|
534
|
+
maxWidth: SCREENCAST_WIDTH,
|
|
535
|
+
maxHeight: SCREENCAST_HEIGHT,
|
|
536
|
+
everyNthFrame: 4,
|
|
523
537
|
});
|
|
524
538
|
}
|
|
525
539
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { v4 as uuid } from 'uuid';
|
|
2
2
|
|
|
3
3
|
import type { BrowserFrame, BrowserViewSurfaceData, ServerMessage } from '../../daemon/ipc-contract.js';
|
|
4
|
-
import { browserManager } from './browser-manager.js';
|
|
4
|
+
import { browserManager, SCREENCAST_HEIGHT,SCREENCAST_WIDTH } from './browser-manager.js';
|
|
5
5
|
|
|
6
6
|
// Track active screencast sessions
|
|
7
7
|
const activeScreencasts = new Map<string, { surfaceId: string }>();
|
|
@@ -161,7 +161,7 @@ export async function getElementBounds(
|
|
|
161
161
|
})()
|
|
162
162
|
`) as { x: number; y: number; w: number; h: number; vw: number; vh: number } | null;
|
|
163
163
|
if (!result) return null;
|
|
164
|
-
const scale = Math.min(
|
|
164
|
+
const scale = Math.min(SCREENCAST_WIDTH / result.vw, SCREENCAST_HEIGHT / result.vh);
|
|
165
165
|
return {
|
|
166
166
|
x: result.x * scale,
|
|
167
167
|
y: result.y * scale,
|
|
@@ -23,7 +23,7 @@ export async function executeCallStart(
|
|
|
23
23
|
return {
|
|
24
24
|
content: [
|
|
25
25
|
'Error: A guardian voice verification call is already active for this number.',
|
|
26
|
-
'Use the guardian outbound verification flow (`/v1/integrations/guardian/outbound/start` or `/resend`) and wait for completion before using `call_start`.',
|
|
26
|
+
'Use the guardian outbound verification flow via the gateway API (`/v1/integrations/guardian/outbound/start` or `/resend`) and wait for completion before using `call_start`.',
|
|
27
27
|
].join(' '),
|
|
28
28
|
isError: true,
|
|
29
29
|
};
|
package/src/tools/executor.ts
CHANGED
|
@@ -58,7 +58,7 @@ export class ToolExecutor {
|
|
|
58
58
|
|
|
59
59
|
// Run pre-execution approval gates (abort, parental controls, guardian
|
|
60
60
|
// policy, allowed-tool-set, task-run preflight, tool registry lookup).
|
|
61
|
-
const gateResult = this.approvalHandler.checkPreExecutionGates(
|
|
61
|
+
const gateResult = await this.approvalHandler.checkPreExecutionGates(
|
|
62
62
|
name, input, context, executionTarget, riskLevel, startTime,
|
|
63
63
|
(event) => emitLifecycleEvent(context, event),
|
|
64
64
|
);
|
|
@@ -70,24 +70,29 @@ export class ToolExecutor {
|
|
|
70
70
|
const tool = gateResult.tool;
|
|
71
71
|
|
|
72
72
|
try {
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
73
|
+
// A consumed scoped grant is a complete authorization — skip the
|
|
74
|
+
// interactive permission/prompt flow so non-interactive sessions
|
|
75
|
+
// don't auto-deny prompt-gated tools and burn the one-time grant.
|
|
76
|
+
if (!gateResult.grantConsumed) {
|
|
77
|
+
// Check permissions via the extracted PermissionChecker
|
|
78
|
+
const permResult = await this.permissionChecker.checkPermission(
|
|
79
|
+
name,
|
|
80
|
+
input,
|
|
81
|
+
tool,
|
|
82
|
+
context,
|
|
83
|
+
executionTarget,
|
|
84
|
+
(event) => emitLifecycleEvent(context, event),
|
|
85
|
+
sanitizeToolInput,
|
|
86
|
+
startTime,
|
|
87
|
+
computePreviewDiff,
|
|
88
|
+
);
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
riskLevel = permResult.riskLevel;
|
|
91
|
+
decision = permResult.decision;
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
if (!permResult.allowed) {
|
|
94
|
+
return { content: permResult.content, isError: true };
|
|
95
|
+
}
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
const hookResult = await getHookManager().trigger('pre-tool-execute', {
|