@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.
Files changed (169) hide show
  1. package/Dockerfile +7 -7
  2. package/docs/architecture/memory.md +11 -2
  3. package/docs/desktop-browser-cli.md +4 -2
  4. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  5. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  6. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  7. package/openapi.yaml +157 -55
  8. package/package.json +5 -4
  9. package/scripts/bundled-plugin-packages.ts +154 -0
  10. package/scripts/generate-bundled-plugin-packages.ts +29 -0
  11. package/scripts/postinstall.ts +33 -0
  12. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  13. package/scripts/test.ts +15 -13
  14. package/src/__tests__/agent-loop.test.ts +124 -0
  15. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  16. package/src/__tests__/channel-approval.test.ts +9 -14
  17. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  18. package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
  19. package/src/__tests__/managed-store.test.ts +121 -0
  20. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  21. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  22. package/src/__tests__/scaffold-managed-skill-tool.test.ts +88 -0
  23. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  24. package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
  25. package/src/__tests__/terminal-tools.test.ts +8 -0
  26. package/src/__tests__/unicode.test.ts +36 -0
  27. package/src/agent/loop.ts +19 -0
  28. package/src/api/events/desktop-activity-changed.ts +10 -0
  29. package/src/api/index.ts +6 -0
  30. package/src/approvals/approval-primitive.ts +5 -2
  31. package/src/approvals/scoped-approval-grants.ts +6 -2
  32. package/src/calls/__tests__/voice-control-protocol.test.ts +24 -2
  33. package/src/calls/__tests__/voice-session-bridge.test.ts +22 -2
  34. package/src/calls/voice-control-protocol.ts +13 -4
  35. package/src/calls/voice-session-bridge.ts +27 -6
  36. package/src/cli/commands/__tests__/plugins.test.ts +66 -0
  37. package/src/cli/commands/plugins.ts +50 -18
  38. package/src/cli/lib/__tests__/install-from-github.test.ts +67 -0
  39. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +169 -0
  40. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +57 -0
  41. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +14 -0
  42. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +27 -1
  43. package/src/cli/lib/__tests__/plugin-details.test.ts +9 -2
  44. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +20 -0
  45. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +43 -0
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +31 -0
  47. package/src/cli/lib/bundled-plugin-packages.json +4 -0
  48. package/src/cli/lib/bundled-plugin-packages.ts +87 -0
  49. package/src/cli/lib/diff-plugin.ts +1 -1
  50. package/src/cli/lib/inspect-plugin.ts +80 -12
  51. package/src/cli/lib/install-from-github.ts +133 -76
  52. package/src/cli/lib/plugin-catalog-cache.ts +22 -3
  53. package/src/cli/lib/plugin-catalog-local.ts +13 -3
  54. package/src/cli/lib/plugin-catalog-platform.ts +6 -1
  55. package/src/cli/lib/plugin-catalog-resolve.ts +20 -0
  56. package/src/cli/lib/plugin-details.ts +12 -0
  57. package/src/cli/lib/plugin-marketplace.ts +121 -21
  58. package/src/cli/lib/plugin-pin-history.ts +5 -2
  59. package/src/cli/lib/search-plugins.ts +58 -16
  60. package/src/cli/lib/upgrade-plugin.ts +48 -3
  61. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  62. package/src/config/bundled-skills/skill-management/TOOLS.json +6 -6
  63. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  64. package/src/daemon/conversation-agent-loop.ts +2 -0
  65. package/src/daemon/conversation-tool-setup.ts +61 -23
  66. package/src/daemon/conversation.ts +17 -0
  67. package/src/daemon/daemon-control.ts +2 -6
  68. package/src/daemon/orphan-reaper.ts +4 -3
  69. package/src/daemon/tool-setup-types.ts +6 -0
  70. package/src/daemon/wake-conversation-ops.ts +38 -15
  71. package/src/desktop/desktop-automation-lease.test.ts +143 -0
  72. package/src/desktop/desktop-automation-lease.ts +39 -3
  73. package/src/live-voice/__tests__/live-voice-vad.test.ts +624 -3
  74. package/src/live-voice/__tests__/session-controls.test.ts +18 -0
  75. package/src/live-voice/live-voice-session.ts +575 -53
  76. package/src/live-voice/session-controls.ts +7 -3
  77. package/src/messaging/provider-message-metadata.ts +3 -3
  78. package/src/monitoring/plugin-auto-update.ts +6 -0
  79. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  80. package/src/notifications/copy-composer.ts +11 -3
  81. package/src/persistence/conversation-plugin-facade.ts +13 -0
  82. package/src/persistence/conversation-tool-surface.ts +86 -0
  83. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  84. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  85. package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
  86. package/src/persistence/schema/index.ts +1 -0
  87. package/src/persistence/steps.ts +2 -0
  88. package/src/plugin-api/conversation-turn.ts +31 -7
  89. package/src/plugin-api/index.ts +9 -1
  90. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  91. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  92. package/src/plugins/defaults/memory/AGENTS.md +14 -2
  93. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
  94. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  95. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  96. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
  97. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +5 -0
  98. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  99. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  100. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  101. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  102. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  103. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  104. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  105. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  106. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  107. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  108. package/src/plugins/defaults/memory/injectors.ts +4 -3
  109. package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
  110. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  111. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  112. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  113. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  114. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  115. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  116. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  117. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  118. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  119. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  120. package/src/plugins/defaults/memory/tools.ts +1 -1
  121. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  122. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  123. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  124. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  125. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  126. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  127. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  128. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  129. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
  130. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  131. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  132. package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
  133. package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
  134. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  135. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
  136. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  137. package/src/runtime/AGENTS.md +1 -1
  138. package/src/runtime/__tests__/agent-wake.test.ts +86 -1
  139. package/src/runtime/agent-wake.ts +20 -4
  140. package/src/runtime/guardian-action-service.ts +2 -17
  141. package/src/runtime/guardian-reply-router.ts +1 -8
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +102 -0
  143. package/src/runtime/routes/channel-route-shared.ts +1 -9
  144. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  145. package/src/runtime/routes/desktop-setup-routes.ts +7 -3
  146. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  147. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  148. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  149. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  150. package/src/runtime/routes/plugins-routes.ts +69 -49
  151. package/src/schedule/run-script.ts +2 -2
  152. package/src/skills/managed-store.ts +98 -32
  153. package/src/tools/host-terminal/host-shell.ts +12 -6
  154. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  155. package/src/tools/shared/shell-output.test.ts +10 -0
  156. package/src/tools/shared/shell-output.ts +14 -2
  157. package/src/tools/skills/find-similar-skills.test.ts +3 -0
  158. package/src/tools/skills/resolve-execute-invocation.ts +24 -0
  159. package/src/tools/skills/sandbox-runner.ts +13 -2
  160. package/src/tools/skills/scaffold-managed.ts +18 -14
  161. package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
  162. package/src/tools/terminal/safe-env.ts +30 -1
  163. package/src/tools/terminal/sanitized-bash.ts +15 -2
  164. package/src/tools/terminal/shell-launch.test.ts +162 -0
  165. package/src/tools/terminal/shell.test.ts +29 -0
  166. package/src/tools/terminal/shell.ts +13 -7
  167. package/src/util/host-process.test.ts +17 -1
  168. package/src/util/host-process.ts +24 -0
  169. 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 (undefined when absent or empty). Shared by the
53
- * includes / activation_hints / avoid_when inputs so they behave identically;
54
- * whether an absent value is acceptable is the caller's call.
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.length > 0 ? normalized : undefined };
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; `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
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, and scaffolding rewrites
235
- // the whole SKILL.md, so a write without them leaves (or strips) none.
236
- if (!activationHints) {
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). Blank or
317
- // whitespace-only values become undefined so they never land in frontmatter.
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
- const normalized = input.category.trim().toLowerCase();
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 { 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).