@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.
Files changed (120) hide show
  1. package/docs/architecture/memory.md +11 -2
  2. package/docs/desktop-browser-cli.md +4 -2
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  4. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  6. package/openapi.yaml +6 -2
  7. package/package.json +2 -2
  8. package/scripts/postinstall.ts +33 -0
  9. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  10. package/src/__tests__/agent-loop.test.ts +124 -0
  11. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  12. package/src/__tests__/channel-approval.test.ts +9 -14
  13. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  14. package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
  15. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  16. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  17. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  18. package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
  19. package/src/__tests__/terminal-tools.test.ts +8 -0
  20. package/src/__tests__/unicode.test.ts +36 -0
  21. package/src/agent/loop.ts +19 -0
  22. package/src/api/events/desktop-activity-changed.ts +10 -0
  23. package/src/api/index.ts +6 -0
  24. package/src/approvals/approval-primitive.ts +5 -2
  25. package/src/approvals/scoped-approval-grants.ts +6 -2
  26. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  27. package/src/daemon/conversation-agent-loop.ts +2 -0
  28. package/src/daemon/conversation-tool-setup.ts +57 -1
  29. package/src/daemon/conversation.ts +17 -0
  30. package/src/daemon/daemon-control.ts +2 -6
  31. package/src/daemon/orphan-reaper.ts +4 -3
  32. package/src/daemon/tool-setup-types.ts +6 -0
  33. package/src/daemon/wake-conversation-ops.ts +38 -15
  34. package/src/desktop/desktop-automation-lease.test.ts +143 -0
  35. package/src/desktop/desktop-automation-lease.ts +39 -3
  36. package/src/messaging/provider-message-metadata.ts +3 -3
  37. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  38. package/src/notifications/copy-composer.ts +11 -3
  39. package/src/persistence/conversation-plugin-facade.ts +13 -0
  40. package/src/persistence/conversation-tool-surface.ts +86 -0
  41. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  42. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  43. package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
  44. package/src/persistence/schema/index.ts +1 -0
  45. package/src/persistence/steps.ts +2 -0
  46. package/src/plugin-api/conversation-turn.ts +31 -7
  47. package/src/plugin-api/index.ts +9 -1
  48. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  49. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  50. package/src/plugins/defaults/memory/AGENTS.md +14 -2
  51. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
  52. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  53. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  54. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
  55. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  56. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  57. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  58. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  59. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  60. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  61. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  62. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  63. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  64. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  65. package/src/plugins/defaults/memory/injectors.ts +4 -3
  66. package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
  67. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  68. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  69. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  70. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  71. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  72. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  73. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  74. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  75. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  76. package/src/plugins/defaults/memory/tools.ts +1 -1
  77. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  78. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  79. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  80. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  81. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  82. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  83. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  84. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  85. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
  86. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  87. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  88. package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
  89. package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
  90. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  91. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
  92. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  93. package/src/runtime/AGENTS.md +1 -1
  94. package/src/runtime/__tests__/agent-wake.test.ts +86 -1
  95. package/src/runtime/agent-wake.ts +20 -4
  96. package/src/runtime/guardian-action-service.ts +2 -17
  97. package/src/runtime/guardian-reply-router.ts +1 -8
  98. package/src/runtime/routes/channel-route-shared.ts +1 -9
  99. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  100. package/src/runtime/routes/desktop-setup-routes.ts +7 -3
  101. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  102. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  103. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  104. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  105. package/src/schedule/run-script.ts +2 -2
  106. package/src/tools/host-terminal/host-shell.ts +12 -6
  107. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  108. package/src/tools/shared/shell-output.test.ts +10 -0
  109. package/src/tools/shared/shell-output.ts +14 -2
  110. package/src/tools/skills/sandbox-runner.ts +13 -2
  111. package/src/tools/skills/scaffold-managed.ts +2 -1
  112. package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
  113. package/src/tools/terminal/safe-env.ts +30 -1
  114. package/src/tools/terminal/sanitized-bash.ts +15 -2
  115. package/src/tools/terminal/shell-launch.test.ts +162 -0
  116. package/src/tools/terminal/shell.test.ts +29 -0
  117. package/src/tools/terminal/shell.ts +13 -7
  118. package/src/util/host-process.test.ts +17 -1
  119. package/src/util/host-process.ts +24 -0
  120. 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
- detached: true,
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; `title`/`body` are the home feed's
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 { pathListDelimiter } from "@vellumai/environments/shell";
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
- windowsHide: true,
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
- detached: true,
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
- detached: true,
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
+ });
@@ -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;
@@ -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).