@vellumai/assistant 0.12.2-staging.5 → 0.12.2-staging.7
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/Dockerfile +7 -7
- package/docs/architecture/memory.md +11 -2
- package/docs/desktop-browser-cli.md +4 -2
- package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
- package/node_modules/@vellumai/environments/src/shell.ts +24 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
- package/openapi.yaml +157 -55
- package/package.json +5 -4
- package/scripts/bundled-plugin-packages.ts +154 -0
- package/scripts/generate-bundled-plugin-packages.ts +29 -0
- package/scripts/postinstall.ts +33 -0
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- package/scripts/test.ts +15 -13
- package/src/__tests__/agent-loop.test.ts +124 -0
- package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
- package/src/__tests__/channel-approval.test.ts +9 -14
- package/src/__tests__/conversation-agent-loop.test.ts +25 -0
- package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
- package/src/__tests__/managed-store.test.ts +121 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
- package/src/__tests__/terminal-tools.test.ts +8 -0
- package/src/__tests__/unicode.test.ts +36 -0
- package/src/agent/loop.ts +19 -0
- package/src/api/events/desktop-activity-changed.ts +10 -0
- package/src/api/index.ts +6 -0
- package/src/approvals/approval-primitive.ts +5 -2
- package/src/approvals/scoped-approval-grants.ts +6 -2
- package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
- package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
- package/src/calls/voice-control-protocol.ts +13 -4
- package/src/calls/voice-session-bridge.ts +27 -6
- package/src/cli/commands/__tests__/plugins.test.ts +66 -0
- package/src/cli/commands/plugins.ts +50 -18
- package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
- package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
- package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
- package/src/cli/lib/bundled-plugin-packages.json +4 -0
- package/src/cli/lib/bundled-plugin-packages.ts +87 -0
- package/src/cli/lib/diff-plugin.ts +1 -1
- package/src/cli/lib/inspect-plugin.ts +80 -12
- package/src/cli/lib/install-from-github.ts +133 -76
- package/src/cli/lib/plugin-catalog-cache.ts +22 -3
- package/src/cli/lib/plugin-catalog-local.ts +13 -3
- package/src/cli/lib/plugin-catalog-platform.ts +6 -1
- package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
- package/src/cli/lib/plugin-details.ts +12 -0
- package/src/cli/lib/plugin-marketplace.ts +121 -21
- package/src/cli/lib/plugin-pin-history.ts +5 -2
- package/src/cli/lib/search-plugins.ts +58 -16
- package/src/cli/lib/upgrade-plugin.ts +48 -3
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +6 -6
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-tool-setup.ts +61 -23
- package/src/daemon/conversation.ts +17 -0
- package/src/daemon/daemon-control.ts +2 -6
- package/src/daemon/orphan-reaper.ts +4 -3
- package/src/daemon/tool-setup-types.ts +6 -0
- package/src/daemon/wake-conversation-ops.ts +38 -15
- package/src/desktop/desktop-automation-lease.test.ts +143 -0
- package/src/desktop/desktop-automation-lease.ts +39 -3
- package/src/live-voice/__tests__/live-voice-vad.test.ts +624 -3
- package/src/live-voice/__tests__/session-controls.test.ts +18 -0
- package/src/live-voice/live-voice-session.ts +575 -53
- package/src/live-voice/session-controls.ts +7 -3
- package/src/messaging/provider-message-metadata.ts +3 -3
- package/src/monitoring/plugin-auto-update.ts +6 -0
- package/src/notifications/__tests__/copy-composer.test.ts +70 -0
- package/src/notifications/copy-composer.ts +11 -3
- package/src/persistence/conversation-plugin-facade.ts +13 -0
- package/src/persistence/conversation-tool-surface.ts +86 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
- package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/steps.ts +2 -0
- package/src/plugin-api/conversation-turn.ts +31 -7
- package/src/plugin-api/index.ts +9 -1
- package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
- package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
- package/src/plugins/defaults/memory/AGENTS.md +14 -2
- package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
- package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +5 -0
- package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
- package/src/plugins/defaults/memory/buffer-file.ts +354 -0
- package/src/plugins/defaults/memory/buffer-format.ts +40 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
- package/src/plugins/defaults/memory/context-search/format.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
- package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
- package/src/plugins/defaults/memory/host-utils.ts +0 -10
- package/src/plugins/defaults/memory/injectors.ts +4 -3
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
- package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
- package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
- package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
- package/src/plugins/defaults/memory/tools.ts +1 -1
- package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
- package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
- package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
- package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
- package/src/plugins/defaults/memory/v3/card.ts +2 -1
- package/src/plugins/defaults/memory/v3/injector.ts +212 -178
- package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
- package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
- package/src/plugins/defaults/memory/v3/sections.ts +2 -1
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
- package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
- package/src/runtime/AGENTS.md +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +86 -1
- package/src/runtime/agent-wake.ts +20 -4
- package/src/runtime/guardian-action-service.ts +2 -17
- package/src/runtime/guardian-reply-router.ts +1 -8
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
- package/src/runtime/routes/channel-route-shared.ts +1 -9
- package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
- package/src/runtime/routes/desktop-setup-routes.ts +7 -3
- package/src/runtime/routes/guardian-approval-interception.ts +24 -0
- package/src/runtime/routes/inbound-message-handler.ts +2 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
- package/src/runtime/routes/plugins-routes.ts +69 -49
- package/src/schedule/run-script.ts +2 -2
- package/src/skills/managed-store.ts +98 -32
- package/src/tools/host-terminal/host-shell.ts +12 -6
- package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
- package/src/tools/shared/shell-output.test.ts +10 -0
- package/src/tools/shared/shell-output.ts +14 -2
- package/src/tools/skills/find-similar-skills.test.ts +3 -0
- package/src/tools/skills/resolve-execute-invocation.ts +24 -0
- package/src/tools/skills/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +18 -14
- package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
- package/src/tools/terminal/safe-env.ts +30 -1
- package/src/tools/terminal/sanitized-bash.ts +15 -2
- package/src/tools/terminal/shell-launch.test.ts +162 -0
- package/src/tools/terminal/shell.test.ts +29 -0
- package/src/tools/terminal/shell.ts +13 -7
- package/src/util/host-process.test.ts +17 -1
- package/src/util/host-process.ts +24 -0
- package/src/util/unicode.ts +29 -0
|
@@ -49,9 +49,12 @@ const MISSING_CHANGE_SUMMARY =
|
|
|
49
49
|
/**
|
|
50
50
|
* Validate + normalize a string-array input (sanitize, drop blanks, dedupe).
|
|
51
51
|
* Returns `{ error }` on the first invalid element, or `{ value }` holding
|
|
52
|
-
* the normalized array
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* the normalized array: undefined when the input is absent, and an empty
|
|
53
|
+
* array when it was passed empty, since on an overwrite the store keeps the
|
|
54
|
+
* current value for an absent field and clears it for an empty one. Shared by
|
|
55
|
+
* the includes / activation_hints / avoid_when inputs so they behave
|
|
56
|
+
* identically; whether an absent or empty value is acceptable is the
|
|
57
|
+
* caller's call.
|
|
55
58
|
* Each element goes through sanitizeFrontmatterValue: activation_hints /
|
|
56
59
|
* avoid_when are concatenated verbatim into capability memory text (see
|
|
57
60
|
* buildSkillContent), so an embedded newline could otherwise smuggle an extra
|
|
@@ -84,7 +87,7 @@ function normalizeOptionalStringArray(
|
|
|
84
87
|
seen.add(cleaned);
|
|
85
88
|
normalized.push(cleaned);
|
|
86
89
|
}
|
|
87
|
-
return { value: normalized
|
|
90
|
+
return { value: normalized };
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/**
|
|
@@ -131,7 +134,8 @@ function notifyBackgroundSkillUpdate(args: {
|
|
|
131
134
|
sourceEventName: "activity.complete",
|
|
132
135
|
dedupeKey: `skill-updated:${args.skillId}:${day}`,
|
|
133
136
|
contextPayload: {
|
|
134
|
-
// `summary` feeds the copy composer
|
|
137
|
+
// `summary` feeds the copy composer and `title` is the headline both
|
|
138
|
+
// it and the home feed keep; `title`/`body` are also the feed's
|
|
135
139
|
// fallback when no channel copy was rendered. Without them a fully
|
|
136
140
|
// suppressed delivery (the intended shape for this signal: low urgency,
|
|
137
141
|
// background, no interruption) leaves the feed writer with no summary
|
|
@@ -231,9 +235,11 @@ export async function executeScaffoldManagedSkill(
|
|
|
231
235
|
return { content: `Error: ${activationHintsResult.error}`, isError: true };
|
|
232
236
|
}
|
|
233
237
|
const activationHints = activationHintsResult.value;
|
|
234
|
-
// Hints are the skill's "Use when:" retrieval text
|
|
235
|
-
//
|
|
236
|
-
|
|
238
|
+
// Hints are the skill's "Use when:" retrieval text and should track the
|
|
239
|
+
// body, so every write states them: the store would keep the current ones
|
|
240
|
+
// on an overwrite, but hints for a rewritten procedure are the caller's
|
|
241
|
+
// to restate, not the store's to assume.
|
|
242
|
+
if (!activationHints || activationHints.length === 0) {
|
|
237
243
|
return {
|
|
238
244
|
content: `Error: ${MISSING_ACTIVATION_HINTS}`,
|
|
239
245
|
isError: true,
|
|
@@ -313,8 +319,9 @@ export async function executeScaffoldManagedSkill(
|
|
|
313
319
|
}
|
|
314
320
|
|
|
315
321
|
// Validate and normalize the optional category (lowercased/trimmed for
|
|
316
|
-
// consistency with the lowercase Skills-UI sidebar buckets).
|
|
317
|
-
//
|
|
322
|
+
// consistency with the lowercase Skills-UI sidebar buckets). A blank value
|
|
323
|
+
// is passed through as the store's "clear" signal; the frontmatter builder
|
|
324
|
+
// never writes a blank category.
|
|
318
325
|
let category: string | undefined;
|
|
319
326
|
if (input.category !== undefined) {
|
|
320
327
|
if (typeof input.category !== "string") {
|
|
@@ -323,10 +330,7 @@ export async function executeScaffoldManagedSkill(
|
|
|
323
330
|
isError: true,
|
|
324
331
|
};
|
|
325
332
|
}
|
|
326
|
-
|
|
327
|
-
if (normalized) {
|
|
328
|
-
category = normalized;
|
|
329
|
-
}
|
|
333
|
+
category = input.category.trim().toLowerCase();
|
|
330
334
|
}
|
|
331
335
|
|
|
332
336
|
// The update notice's body. Model-authored text bound for a notification
|
|
@@ -111,6 +111,35 @@ describe("safe-env Windows forwarding", () => {
|
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
test("prepends the Windows assistant binary directory when it sits beside the runtime", () => {
|
|
115
|
+
const dir = mkdtempSync(join(tmpdir(), "win-assistant-"));
|
|
116
|
+
writeFileSync(join(dir, "assistant.exe"), "");
|
|
117
|
+
try {
|
|
118
|
+
const env = buildSanitizedEnv(
|
|
119
|
+
"win32",
|
|
120
|
+
{ Path: "C:\\Windows\\System32" },
|
|
121
|
+
{ execPath: join(dir, "vellum-daemon.exe") },
|
|
122
|
+
);
|
|
123
|
+
expect(env.PATH).toBe(`${dir};C:\\Windows\\System32`);
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(dir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("does not invent a Windows assistant path when the sibling binary is absent", () => {
|
|
130
|
+
const dir = mkdtempSync(join(tmpdir(), "win-no-assistant-"));
|
|
131
|
+
try {
|
|
132
|
+
const env = buildSanitizedEnv(
|
|
133
|
+
"win32",
|
|
134
|
+
{ Path: "C:\\Windows\\System32" },
|
|
135
|
+
{ execPath: join(dir, "vellum-daemon.exe") },
|
|
136
|
+
);
|
|
137
|
+
expect(env.PATH).toBe("C:\\Windows\\System32");
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(dir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
114
143
|
test("matches Windows environment names case-insensitively", () => {
|
|
115
144
|
const windowsEnv = buildSanitizedEnv("win32", {
|
|
116
145
|
Path: "C:\\Windows\\System32;C:\\Program Files\\Vellum",
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readdirSync, statSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
10
11
|
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
pathListDelimiter,
|
|
14
|
+
prependUniquePathEntries,
|
|
15
|
+
} from "@vellumai/environments/shell";
|
|
12
16
|
|
|
13
17
|
import { getGatewayInternalBaseUrl } from "../../config/env.js";
|
|
14
18
|
import { getDataDir, getWorkspaceDir } from "../../util/platform.js";
|
|
@@ -284,6 +288,7 @@ function appendUniquePathEntries(
|
|
|
284
288
|
export function buildSanitizedEnv(
|
|
285
289
|
hostPlatform: NodeJS.Platform = process.platform,
|
|
286
290
|
sourceEnv: NodeJS.ProcessEnv = process.env,
|
|
291
|
+
options?: { execPath?: string },
|
|
287
292
|
): Record<string, string> {
|
|
288
293
|
const env: Record<string, string> = {};
|
|
289
294
|
const isKataRuntime = isKataFamilyRuntime(sourceEnv.VELLUM_SANDBOX_RUNTIME);
|
|
@@ -360,5 +365,29 @@ export function buildSanitizedEnv(
|
|
|
360
365
|
if (!env.LC_ALL) {
|
|
361
366
|
env.LC_ALL = utf8Locale;
|
|
362
367
|
}
|
|
368
|
+
prependWindowsAssistantDir(
|
|
369
|
+
env,
|
|
370
|
+
hostPlatform,
|
|
371
|
+
options?.execPath ?? process.execPath,
|
|
372
|
+
);
|
|
363
373
|
return env;
|
|
364
374
|
}
|
|
375
|
+
|
|
376
|
+
function prependWindowsAssistantDir(
|
|
377
|
+
env: Record<string, string>,
|
|
378
|
+
hostPlatform: NodeJS.Platform,
|
|
379
|
+
execPath: string,
|
|
380
|
+
): void {
|
|
381
|
+
if (hostPlatform !== "win32") {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const execDir = dirname(execPath);
|
|
385
|
+
try {
|
|
386
|
+
if (!statSync(join(execDir, "assistant.exe")).isFile()) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
env.PATH = prependUniquePathEntries(env.PATH, [execDir], hostPlatform);
|
|
393
|
+
}
|
|
@@ -11,9 +11,12 @@ import { spawn } from "node:child_process";
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
buildShellInvocation,
|
|
14
|
+
buildShellSpawnFlags,
|
|
14
15
|
terminateProcessTree,
|
|
16
|
+
watchShellProcessStart,
|
|
15
17
|
} from "../../util/host-process.js";
|
|
16
18
|
import { getWorkspaceDir } from "../../util/platform.js";
|
|
19
|
+
import { SHELL_DID_NOT_START_MESSAGE } from "../shared/shell-output.js";
|
|
17
20
|
import { buildSanitizedEnv } from "./safe-env.js";
|
|
18
21
|
|
|
19
22
|
export const DEFAULT_SANITIZED_BASH_TIMEOUT_MS = 30_000;
|
|
@@ -63,10 +66,10 @@ export function runSanitizedBash(
|
|
|
63
66
|
const child = spawn(wrapped.command, wrapped.args, {
|
|
64
67
|
cwd: getWorkspaceDir(),
|
|
65
68
|
stdio: ["ignore", "pipe", "pipe"],
|
|
66
|
-
detached: true,
|
|
67
69
|
env: buildSanitizedEnv(),
|
|
68
|
-
|
|
70
|
+
...buildShellSpawnFlags(),
|
|
69
71
|
});
|
|
72
|
+
const launch = watchShellProcessStart(child);
|
|
70
73
|
|
|
71
74
|
const timer = setTimeout(() => {
|
|
72
75
|
timedOut = true;
|
|
@@ -83,6 +86,16 @@ export function runSanitizedBash(
|
|
|
83
86
|
|
|
84
87
|
child.on("close", (code) => {
|
|
85
88
|
clearTimeout(timer);
|
|
89
|
+
if (!launch.didStart()) {
|
|
90
|
+
finish({
|
|
91
|
+
stdout: "",
|
|
92
|
+
stderr: "",
|
|
93
|
+
exitCode: null,
|
|
94
|
+
timedOut: false,
|
|
95
|
+
error: SHELL_DID_NOT_START_MESSAGE,
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
86
99
|
finish({
|
|
87
100
|
stdout: Buffer.concat(stdoutChunks).toString(),
|
|
88
101
|
stderr: Buffer.concat(stderrChunks).toString(),
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as realChildProcess from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import { setConfig } from "../../__tests__/helpers/set-config.js";
|
|
9
|
+
import { SHELL_DID_NOT_START_MESSAGE } from "../shared/shell-output.js";
|
|
10
|
+
import type { ToolContext } from "../types.js";
|
|
11
|
+
|
|
12
|
+
const originalSpawn = realChildProcess.spawn;
|
|
13
|
+
|
|
14
|
+
type FakeChild = EventEmitter & {
|
|
15
|
+
pid?: number;
|
|
16
|
+
stdout: EventEmitter;
|
|
17
|
+
stderr: EventEmitter;
|
|
18
|
+
kill: () => boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type SpawnImpl = (
|
|
22
|
+
...args: Parameters<typeof realChildProcess.spawn>
|
|
23
|
+
) => ReturnType<typeof realChildProcess.spawn>;
|
|
24
|
+
|
|
25
|
+
const spawnCalls: {
|
|
26
|
+
command: string;
|
|
27
|
+
args: unknown;
|
|
28
|
+
options: realChildProcess.SpawnOptions;
|
|
29
|
+
}[] = [];
|
|
30
|
+
|
|
31
|
+
let spawnImpl: SpawnImpl = originalSpawn as SpawnImpl;
|
|
32
|
+
|
|
33
|
+
const spawnSpy = mock((...args: Parameters<typeof realChildProcess.spawn>) => {
|
|
34
|
+
spawnCalls.push({
|
|
35
|
+
command: args[0] as string,
|
|
36
|
+
args: args[1],
|
|
37
|
+
options: (args[2] ?? {}) as realChildProcess.SpawnOptions,
|
|
38
|
+
});
|
|
39
|
+
return spawnImpl(...args);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
mock.module("node:child_process", () => ({
|
|
43
|
+
...realChildProcess,
|
|
44
|
+
spawn: spawnSpy,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const realLogger = await import("../../util/logger.js");
|
|
48
|
+
mock.module("../../util/logger.js", () => ({
|
|
49
|
+
...realLogger,
|
|
50
|
+
getLogger: () =>
|
|
51
|
+
new Proxy({} as Record<string, unknown>, {
|
|
52
|
+
get: () => () => {},
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
setConfig("timeouts", { shellDefaultTimeoutSec: 30, shellMaxTimeoutSec: 60 });
|
|
57
|
+
|
|
58
|
+
const realHub = await import("../../runtime/assistant-event-hub.js");
|
|
59
|
+
mock.module("../../runtime/assistant-event-hub.js", () => ({
|
|
60
|
+
...realHub,
|
|
61
|
+
broadcastMessage: () => {},
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const realWake = await import("../../runtime/agent-wake.js");
|
|
65
|
+
mock.module("../../runtime/agent-wake.js", () => ({
|
|
66
|
+
...realWake,
|
|
67
|
+
wakeAgentForOpportunity: async () => ({}),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const { shellTool } = await import("./shell.js");
|
|
71
|
+
|
|
72
|
+
function makeContext(workingDir: string): ToolContext {
|
|
73
|
+
return {
|
|
74
|
+
workingDir,
|
|
75
|
+
conversationId: "conv-xyz",
|
|
76
|
+
trustClass: "guardian",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fakeChild(): FakeChild {
|
|
81
|
+
const child = new EventEmitter() as FakeChild;
|
|
82
|
+
child.stdout = new EventEmitter();
|
|
83
|
+
child.stderr = new EventEmitter();
|
|
84
|
+
child.kill = () => true;
|
|
85
|
+
return child;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function closeWithoutStart(): ReturnType<typeof realChildProcess.spawn> {
|
|
89
|
+
const child = fakeChild();
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
child.emit("close", 0, null);
|
|
92
|
+
});
|
|
93
|
+
return child as unknown as ReturnType<typeof realChildProcess.spawn>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function spawnError(
|
|
97
|
+
err: NodeJS.ErrnoException,
|
|
98
|
+
): ReturnType<typeof realChildProcess.spawn> {
|
|
99
|
+
const child = fakeChild();
|
|
100
|
+
queueMicrotask(() => {
|
|
101
|
+
child.emit("error", err);
|
|
102
|
+
});
|
|
103
|
+
return child as unknown as ReturnType<typeof realChildProcess.spawn>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("bash launch failures are not success", () => {
|
|
107
|
+
let workingDir = "";
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
spawnCalls.length = 0;
|
|
111
|
+
spawnImpl = originalSpawn as SpawnImpl;
|
|
112
|
+
workingDir = mkdtempSync(join(tmpdir(), "bash-launch-"));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
spawnImpl = originalSpawn as SpawnImpl;
|
|
117
|
+
rmSync(workingDir, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a close with exit 0 and no process start is an error, not command_completed", async () => {
|
|
121
|
+
spawnImpl = () => closeWithoutStart();
|
|
122
|
+
const proof = join(workingDir, "proof.txt");
|
|
123
|
+
|
|
124
|
+
const result = await shellTool.execute(
|
|
125
|
+
{
|
|
126
|
+
command: "printf ran > proof.txt",
|
|
127
|
+
activity: "test",
|
|
128
|
+
},
|
|
129
|
+
makeContext(workingDir),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(result.isError).toBe(true);
|
|
133
|
+
expect(result.content).toBe(SHELL_DID_NOT_START_MESSAGE);
|
|
134
|
+
expect(result.content).not.toContain("<command_completed />");
|
|
135
|
+
expect(existsSync(proof)).toBe(false);
|
|
136
|
+
expect(spawnCalls).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a failed spawn of the shell is an error", async () => {
|
|
140
|
+
spawnImpl = () => {
|
|
141
|
+
const err = new Error(
|
|
142
|
+
"spawn powershell.exe ENOENT",
|
|
143
|
+
) as NodeJS.ErrnoException;
|
|
144
|
+
err.code = "ENOENT";
|
|
145
|
+
return spawnError(err);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const result = await shellTool.execute(
|
|
149
|
+
{
|
|
150
|
+
command: "assistant browser --help",
|
|
151
|
+
activity: "test",
|
|
152
|
+
},
|
|
153
|
+
makeContext(workingDir),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(result.isError).toBe(true);
|
|
157
|
+
expect(result.content).toContain("Error spawning command");
|
|
158
|
+
expect(result.content).toContain("The command was not found");
|
|
159
|
+
expect(result.content).not.toContain("<command_completed />");
|
|
160
|
+
expect(existsSync(join(workingDir, "help.txt"))).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -7,6 +7,7 @@ import { setConfig } from "../../__tests__/helpers/set-config.js";
|
|
|
7
7
|
import {
|
|
8
8
|
MAX_OUTPUT_LENGTH,
|
|
9
9
|
OUTPUT_TRUNCATED_TAG,
|
|
10
|
+
SHELL_DID_NOT_START_MESSAGE,
|
|
10
11
|
} from "../shared/shell-output.js";
|
|
11
12
|
import type { ToolContext } from "../types.js";
|
|
12
13
|
|
|
@@ -207,6 +208,34 @@ describe("foreground stdin handling", () => {
|
|
|
207
208
|
});
|
|
208
209
|
});
|
|
209
210
|
|
|
211
|
+
describe("foreground command execution", () => {
|
|
212
|
+
let tmpDir = "";
|
|
213
|
+
|
|
214
|
+
beforeEach(async () => {
|
|
215
|
+
tmpDir = await mkdtemp(join(tmpdir(), "bash-exec-"));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
afterEach(async () => {
|
|
219
|
+
if (tmpDir) {
|
|
220
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("a command that writes a file must actually create it", async () => {
|
|
225
|
+
const result = await shellTool.execute(
|
|
226
|
+
{
|
|
227
|
+
command: "printf ran > proof.txt",
|
|
228
|
+
activity: "test",
|
|
229
|
+
},
|
|
230
|
+
{ ...makeContext(), workingDir: tmpDir },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(result.isError).toBeFalsy();
|
|
234
|
+
expect(result.content).not.toBe(SHELL_DID_NOT_START_MESSAGE);
|
|
235
|
+
expect(await readFile(join(tmpDir, "proof.txt"), "utf-8")).toBe("ran");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
210
239
|
describe("streaming stdout cap", () => {
|
|
211
240
|
let tmpDir = "";
|
|
212
241
|
|
|
@@ -11,7 +11,9 @@ import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
|
|
|
11
11
|
import { redactSecrets } from "../../security/secret-scanner.js";
|
|
12
12
|
import {
|
|
13
13
|
buildShellInvocation,
|
|
14
|
+
buildShellSpawnFlags,
|
|
14
15
|
terminateProcessTree,
|
|
16
|
+
watchShellProcessStart,
|
|
15
17
|
} from "../../util/host-process.js";
|
|
16
18
|
import { getLogger } from "../../util/logger.js";
|
|
17
19
|
import { getDataDir } from "../../util/platform.js";
|
|
@@ -324,9 +326,9 @@ export const shellTool = {
|
|
|
324
326
|
cwd: context.workingDir,
|
|
325
327
|
env,
|
|
326
328
|
stdio: ["ignore", "pipe", "pipe"],
|
|
327
|
-
|
|
328
|
-
windowsHide: true,
|
|
329
|
+
...buildShellSpawnFlags(),
|
|
329
330
|
});
|
|
331
|
+
const launch = watchShellProcessStart(child);
|
|
330
332
|
const collector = attachBoundedStdio(child);
|
|
331
333
|
|
|
332
334
|
const killTree = buildKillTree(child, {
|
|
@@ -367,11 +369,13 @@ export const shellTool = {
|
|
|
367
369
|
timedOut,
|
|
368
370
|
});
|
|
369
371
|
|
|
370
|
-
const fmtResult = collector.format(code, timedOut, timeoutSec
|
|
372
|
+
const fmtResult = collector.format(code, timedOut, timeoutSec, {
|
|
373
|
+
started: launch.didStart(),
|
|
374
|
+
});
|
|
371
375
|
|
|
372
376
|
const status: BackgroundToolCompletedEvent["status"] = aborted
|
|
373
377
|
? "cancelled"
|
|
374
|
-
: timedOut
|
|
378
|
+
: timedOut || fmtResult.isError
|
|
375
379
|
? "failed"
|
|
376
380
|
: code === 0
|
|
377
381
|
? "completed"
|
|
@@ -529,9 +533,9 @@ export const shellTool = {
|
|
|
529
533
|
cwd: context.workingDir,
|
|
530
534
|
env,
|
|
531
535
|
stdio: ["ignore", "pipe", "pipe"],
|
|
532
|
-
|
|
533
|
-
windowsHide: true,
|
|
536
|
+
...buildShellSpawnFlags(),
|
|
534
537
|
});
|
|
538
|
+
const launch = watchShellProcessStart(child);
|
|
535
539
|
const collector = attachBoundedStdio(child, {
|
|
536
540
|
onOutput: context.onOutput,
|
|
537
541
|
});
|
|
@@ -573,7 +577,9 @@ export const shellTool = {
|
|
|
573
577
|
timedOut,
|
|
574
578
|
});
|
|
575
579
|
|
|
576
|
-
const fmtResult = collector.format(code, timedOut, timeoutSec
|
|
580
|
+
const fmtResult = collector.format(code, timedOut, timeoutSec, {
|
|
581
|
+
started: launch.didStart(),
|
|
582
|
+
});
|
|
577
583
|
|
|
578
584
|
resolve({
|
|
579
585
|
content: fmtResult.content,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
1
2
|
import { expect, mock, test } from "bun:test";
|
|
2
3
|
|
|
3
|
-
import { terminateProcessTree } from "./host-process.js";
|
|
4
|
+
import { terminateProcessTree, watchShellProcessStart } from "./host-process.js";
|
|
4
5
|
|
|
5
6
|
test("Windows process trees use taskkill and fall back to the direct child", () => {
|
|
6
7
|
const kill = mock(() => true);
|
|
@@ -15,3 +16,18 @@ test("Windows process trees use taskkill and fall back to the direct child", ()
|
|
|
15
16
|
expect(kill).toHaveBeenCalledTimes(1);
|
|
16
17
|
expect(kill).toHaveBeenCalledWith();
|
|
17
18
|
});
|
|
19
|
+
|
|
20
|
+
test("watchShellProcessStart is false until spawn or a pid appears", () => {
|
|
21
|
+
const child = new EventEmitter() as EventEmitter & { pid?: number };
|
|
22
|
+
const launch = watchShellProcessStart(child);
|
|
23
|
+
expect(launch.didStart()).toBe(false);
|
|
24
|
+
|
|
25
|
+
child.emit("spawn");
|
|
26
|
+
expect(launch.didStart()).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("watchShellProcessStart treats a pid assigned at construction as started", () => {
|
|
30
|
+
const child = new EventEmitter() as EventEmitter & { pid?: number };
|
|
31
|
+
child.pid = 4242;
|
|
32
|
+
expect(watchShellProcessStart(child).didStart()).toBe(true);
|
|
33
|
+
});
|
package/src/util/host-process.ts
CHANGED
|
@@ -2,10 +2,34 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
|
|
3
3
|
export {
|
|
4
4
|
buildShellInvocation,
|
|
5
|
+
buildShellSpawnFlags,
|
|
5
6
|
pathListDelimiter,
|
|
6
7
|
prependUniquePathEntries,
|
|
7
8
|
} from "@vellumai/environments/shell";
|
|
8
9
|
|
|
10
|
+
export interface ShellProcessStartWatch {
|
|
11
|
+
didStart(): boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Record whether a spawned child actually started. A `close` with exit 0 and
|
|
16
|
+
* empty stdio is not proof of execution: Windows can emit that when the
|
|
17
|
+
* process was never created. Callers must treat `didStart() === false` as a
|
|
18
|
+
* launch failure.
|
|
19
|
+
*/
|
|
20
|
+
export function watchShellProcessStart(child: {
|
|
21
|
+
pid?: number;
|
|
22
|
+
once(event: "spawn", listener: () => void): unknown;
|
|
23
|
+
}): ShellProcessStartWatch {
|
|
24
|
+
let spawnSeen = child.pid != null;
|
|
25
|
+
child.once("spawn", () => {
|
|
26
|
+
spawnSeen = true;
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
didStart: () => spawnSeen || child.pid != null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
export interface KillableProcess {
|
|
10
34
|
pid?: number;
|
|
11
35
|
kill: unknown;
|
package/src/util/unicode.ts
CHANGED
|
@@ -67,6 +67,35 @@ export function safeStringSlice(
|
|
|
67
67
|
return str.slice(safeStart, safeEnd);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* A `[start, end)` character window of at most `maxChars` code units that
|
|
72
|
+
* never splits a surrogate pair, for paging through text by offset. A split
|
|
73
|
+
* leaves a lone half at each edge, and each encodes to U+FFFD, so the
|
|
74
|
+
* character is lost from both this window and the next one paged in after
|
|
75
|
+
* it. `charCodeAt` reads the underlying text so a caller need not hold it as
|
|
76
|
+
* one string.
|
|
77
|
+
*/
|
|
78
|
+
export function surrogateSafeWindow(
|
|
79
|
+
total: number,
|
|
80
|
+
charCodeAt: (index: number) => number,
|
|
81
|
+
requestedStart: number,
|
|
82
|
+
maxChars: number,
|
|
83
|
+
): { start: number; end: number } {
|
|
84
|
+
let start = Math.max(0, Math.min(requestedStart, total));
|
|
85
|
+
if (start > 0 && start < total && isLowSurrogate(charCodeAt(start))) {
|
|
86
|
+
start -= 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let end = Math.min(total, start + maxChars);
|
|
90
|
+
if (end > start && end < total && isHighSurrogate(charCodeAt(end - 1))) {
|
|
91
|
+
// Backing off would empty a one-character window, which stalls paging on
|
|
92
|
+
// the same offset, so take the whole pair instead.
|
|
93
|
+
end = end - 1 > start ? end - 1 : Math.min(total, end + 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { start, end };
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
/**
|
|
71
100
|
* Replace every orphaned UTF-16 surrogate in `str` with U+FFFD
|
|
72
101
|
* (REPLACEMENT CHARACTER).
|