@vellumai/assistant 0.12.2-staging.6 → 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/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 +6 -2
- package/package.json +2 -2
- package/scripts/postinstall.ts +33 -0
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- 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__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
- 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/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 +57 -1
- 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/messaging/provider-message-metadata.ts +3 -3
- 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-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-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/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/schedule/run-script.ts +2 -2
- 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/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +2 -1
- 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
|
@@ -4,6 +4,9 @@ export const MAX_OUTPUT_LENGTH = 20_000;
|
|
|
4
4
|
|
|
5
5
|
export const OUTPUT_TRUNCATED_TAG = `<output_truncated limit="20K" />`;
|
|
6
6
|
|
|
7
|
+
export const SHELL_DID_NOT_START_MESSAGE =
|
|
8
|
+
"Error: the shell command did not start. No process was created, so the command did not run.";
|
|
9
|
+
|
|
7
10
|
export interface ShellOutputResult {
|
|
8
11
|
content: string;
|
|
9
12
|
status: string | undefined;
|
|
@@ -31,8 +34,16 @@ export function formatShellOutput(
|
|
|
31
34
|
code: number | null,
|
|
32
35
|
timedOut: boolean,
|
|
33
36
|
timeoutSec: number,
|
|
34
|
-
options?: { truncated?: boolean },
|
|
37
|
+
options?: { truncated?: boolean; started?: boolean },
|
|
35
38
|
): ShellOutputResult {
|
|
39
|
+
if (options?.started === false) {
|
|
40
|
+
return {
|
|
41
|
+
content: SHELL_DID_NOT_START_MESSAGE,
|
|
42
|
+
status: undefined,
|
|
43
|
+
isError: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
let output = stdout;
|
|
37
48
|
if (stderr) {
|
|
38
49
|
output += (output ? "\n" : "") + stderr;
|
|
@@ -137,6 +148,7 @@ export class BoundedStdioCollector {
|
|
|
137
148
|
code: number | null,
|
|
138
149
|
timedOut: boolean,
|
|
139
150
|
timeoutSec: number,
|
|
151
|
+
options?: { started?: boolean },
|
|
140
152
|
): ShellOutputResult {
|
|
141
153
|
return formatShellOutput(
|
|
142
154
|
Buffer.concat(this.stdoutParts).toString(),
|
|
@@ -144,7 +156,7 @@ export class BoundedStdioCollector {
|
|
|
144
156
|
code,
|
|
145
157
|
timedOut,
|
|
146
158
|
timeoutSec,
|
|
147
|
-
{ truncated: this.truncated },
|
|
159
|
+
{ truncated: this.truncated, started: options?.started },
|
|
148
160
|
);
|
|
149
161
|
}
|
|
150
162
|
}
|
|
@@ -7,9 +7,12 @@ import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
|
|
|
7
7
|
import { computeSkillVersionHash } from "../../skills/version-hash.js";
|
|
8
8
|
import {
|
|
9
9
|
buildShellInvocation,
|
|
10
|
+
buildShellSpawnFlags,
|
|
10
11
|
terminateProcessTree,
|
|
12
|
+
watchShellProcessStart,
|
|
11
13
|
} from "../../util/host-process.js";
|
|
12
14
|
import { safeStringSlice } from "../../util/unicode.js";
|
|
15
|
+
import { SHELL_DID_NOT_START_MESSAGE } from "../shared/shell-output.js";
|
|
13
16
|
import { buildSanitizedEnv } from "../terminal/safe-env.js";
|
|
14
17
|
import type { ToolContext, ToolExecutionResult } from "../types.js";
|
|
15
18
|
|
|
@@ -160,9 +163,9 @@ function spawnRunner(
|
|
|
160
163
|
cwd: runDir,
|
|
161
164
|
env,
|
|
162
165
|
stdio: ["ignore", "pipe", "pipe"],
|
|
163
|
-
|
|
164
|
-
windowsHide: true,
|
|
166
|
+
...buildShellSpawnFlags(),
|
|
165
167
|
});
|
|
168
|
+
const launch = watchShellProcessStart(child);
|
|
166
169
|
|
|
167
170
|
const timer = setTimeout(() => {
|
|
168
171
|
timedOut = true;
|
|
@@ -188,6 +191,14 @@ function spawnRunner(
|
|
|
188
191
|
clearTimeout(timer);
|
|
189
192
|
context.signal?.removeEventListener("abort", onAbort);
|
|
190
193
|
|
|
194
|
+
if (!launch.didStart()) {
|
|
195
|
+
resolve({
|
|
196
|
+
content: `Failed to spawn skill tool script "${executorPath}": ${SHELL_DID_NOT_START_MESSAGE}`,
|
|
197
|
+
isError: true,
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
191
202
|
if (timedOut) {
|
|
192
203
|
resolve({
|
|
193
204
|
content: `Skill tool script "${executorPath}" timed out after ${timeoutMs}ms`,
|
|
@@ -134,7 +134,8 @@ function notifyBackgroundSkillUpdate(args: {
|
|
|
134
134
|
sourceEventName: "activity.complete",
|
|
135
135
|
dedupeKey: `skill-updated:${args.skillId}:${day}`,
|
|
136
136
|
contextPayload: {
|
|
137
|
-
// `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
|
|
138
139
|
// fallback when no channel copy was rendered. Without them a fully
|
|
139
140
|
// suppressed delivery (the intended shape for this signal: low urgency,
|
|
140
141
|
// background, no interruption) leaves the feed writer with no summary
|
|
@@ -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).
|